Run an AI coding agent on your own machine without handing it your machine.
Coding agents such as Claude Code want to run commands, edit files and install things, and they want to do it fast, without asking. Given the run of your laptop, that means your ssh keys, your cloud tokens, your other repositories, your home directory. Given nothing, they are useless.
nixcage gives each project a cage: a small Linux world in which the agent sees that one project, a persistent home of its own, and the tools the project declares, and nothing else. It cannot see other projects, your files, or your keys. Commits still get signed, because your ssh-agent is forwarded in as a socket while the key stays outside. Secrets the agent needs (an API token, say) reach it as environment variables from your existing sops-nix setup, never from your shell.
- Nothing to add to a project. A project is any directory under a
workspace root you name. The project's own
devShells.defaultis the environment inside the cage when it declares one, so what the agent gets is what the project already says it needs; a directory that declares none gets the base container shell. No Dockerfile, no image, no nixcage file in the repo. - One command.
cdinto a project andnixcage enter; you are in the cage, in the devShell, in the project.nixcage enter -- clauderuns the agent straight away. The home persists, soclaude loginis done once. - A boundary you choose. By default a cage is a systemd-nspawn container: fast to enter, sharing the host's kernel and Nix store. On a Linux host you can instead give a cage a microVM with a kernel of its own, for a tool you trust less or one that needs its own kernel; same command, one flag, decided once per cage. On macOS every cage runs inside one shared Linux VM, which is a boundary of its own.
- Something to build on. nixcage runs cages and has no opinion about what runs in them. A tool that wants several agents sharing one repository, each as its own user on its own private network, gets five primitives and nothing else; see "Building on nixcage" below.
On Linux, nixcage enter runs the container on the host directly. On macOS
there are no containers, so the same container runs inside a shared NixOS
microVM reached over SSH:
nixcage enter (from any directory under a workspace root)
|
+-- Linux: run the container on the host
|
+-- macOS: VM not running? --> boot the shared VM (qemu, microvm.nix)
| then SSH into it
v
+----------[host, or the VM on macOS]--------+
| |
| /nix/store the host's store on |
| Linux, the VM's on |
| macOS |
| ~/Src workspace roots |
| |
| +------[container: this project]-------+ |
| | | |
| | /workspace <- only this project | |
| | /root <- persistent home | |
| | /nix/store <- read-only bind | |
| | | |
| | nix develop (your devShell) | |
| +--------------------------------------+ |
| |
+--------------------------------------------+
Each nixcage enter runs nix develop inside a systemd-nspawn container that
sees only its own project directory, its own persistent home, and the Nix store
read-only. The first enter per project builds the devShell; later enters hit the
cache. On macOS the VM boots once and serves every project.
On a Linux host with nixcage.microvm.enable = true, a cage may run in a
microVM instead: nixcage enter --substrate microvm. The choice is made at
the cage's first enter (or declared in the host config) and then fixed; a
flag against it is refused naming what fixed it. Everything else is the
same enter: the project at /workspace, the persistent home (at
/home/nixcage, since the guest owns /root), the store read-only, your
ssh-agent forwarded, secrets injected, and the nixcage-container verbs
over a cage (exec, status, stop, list).
What differs: a kernel boundary toward the host and toward other cages, no
nix daemon and no builds inside (the session sees the store and cannot add
to it, so put the toolchain in the devShell or realise it elsewhere), a
boot of about seven seconds instead of a tenth, and memory reserved rather
than shared: what nixcage.bounds or --memory/--cpus says, else half this
machine where nothing was declared, else systemd-vmspawn's own 2 GiB and one
vCPU, which is not enough to build in. --disk 2G adds a persistent image at /var/lib for what
virtiofs is too slow for. This is for a tool you trust less than the rest,
or one that needs a kernel of its own (eBPF, mount namespaces, modules).
See docs/ADR-019-a-cage-may-run-in-a-microvm-behind-the-same-enter.md.
A microVM per cage is a kernel boundary around one session. A machine is
one around a group: nixcage.machines.<name> declares a long-lived
microVM that is itself a nixcage host, and every cage entered with
--machine <name> runs inside it as an ordinary nspawn cage. An escape
from any of them lands in the machine, not on this host and not in another
machine. Its disk is a file only qemu opens; what it may see of this host
is the store, read-only, and the directories you share, with anything it
writes there owned by the uid slice you gave it; on a bridge it speaks
only as the addresses you listed.
nixcage.machines.work = {
memory = "4G";
diskSize = "20G";
uidSlice.base = 900000;
shares = [ { path = "/srv/checkout"; } ];
};sudo nixcage-container machine up work
sudo nixcage-container enter --machine work --no-agent c1 /srv/checkout -- make test
sudo nixcage-container machine down workSee docs/ADR-026-a-machine-is-a-microvm-that-is-itself-a-nixcage-host.md.
nix profile install github:hamidr/nixcagecd ~/Src/myproject
nix run github:hamidr/nixcage -- enterOn a Linux host that has declared nothing, the cage is the directory you are
standing in. There is no workspace root to satisfy, because there is no host
configuration to declare one; what is refused instead is the directory nobody
means: /, /nix, /nix/store, your home directory itself, and anything you
do not own. The session's bounds are half of this machine's memory and cores,
announced when the session starts, and its git identity is read from your own
git config (your ~/.gitconfig is never bound in -- it carries
credential.helper and signing configuration).
Cages entered this way live at /var/lib/nixcage, exactly where they live
after you import the module, so nothing is thrown away when you keep nixcage.
nixcage status says what an undeclared host does not have: no secretEnv,
since the host environment is never read, and no declared subjects, so
nixcage-container uid and storage ensure refuse and name the option that
would answer them.
--substrate microvm works here too, and the first session that asks for one
builds the guest and, where this machine has no qemu-system-<arch> of its
own, fetches qemu. What that costs is printed and confirmed before it is
spent, and the result is cached, so only the first microVM session pays.
nix run re-evaluates the flake every time; nix profile install is the
honest next step once you keep it.
Containers run on the host; there is no VM. Import the host module in your NixOS configuration and rebuild:
# flake input: nixcage.url = "github:hamidr/nixcage";
imports = [ inputs.nixcage.nixosModules.host ];
nixcage = {
workspaceRoots = [ "/home/me/Src" ];
## Env vars from this host's sops-nix secrets, per container session.
# secretEnv.ANTHROPIC_API_KEY = "anthropic";
## Let a cage run in a microVM of its own (needs /dev/kvm and systemd 261).
# microvm.enable = true;
# principalUidRange = { base = 700000; size = 64; };
## Fix a cage's substrate from here, over its record and the flag.
# cages."/home/me/Src/untrusted".substrate = "microvm";
## Paths a cage always has, beside whatever a session asks for with --bind.
# cages."/home/me/Src/untrusted".binds = [ "/srv/models:/models:ro" ];
## What a cage runs on when nothing closer decides.
# substrate.default = "nspawn";
## What a cage may use, and what one cage may use. A session's --memory
## and --cpus outrank both. Unset leaves the substrate's own default:
## unbounded on nspawn, 2 GiB and one vCPU on a microVM. On nspawn the
## memory bounds RAM: past it a cage swaps rather than being killed. A host that
## declared nothing at all gets half this machine instead.
# bounds = { memory = "4G"; cpus = 4; };
# cages."/home/me/Src/untrusted".bounds = { memory = "8G"; cpus = 8; };
};nixos-rebuild switch applies it; the CLI is enter, exec, list, rm
and status (rebuild and down are macOS-only, since on Linux the host
owns the lifecycle).
Containers use the host store read-only plus the host nix-daemon.
The VM is configured by a Nix flake you own. Create it from the template:
nix flake new -t github:hamidr/nixcage ~/.config/nixcageEdit it like any NixOS configuration. The nixcage options:
nixcage = {
## Any directory under these roots can be entered.
workspaceRoots = [ "/Users/me/Src" ];
## The key printed by 'nixcage status' on first run.
authorizedKeys = [ "ssh-ed25519 AAAA..." ];
## 9p is the default and the only protocol that works on a macOS host;
## virtiofs needs virtiofsd, which is Linux-only.
# shareProto = "9p";
## Environment variables injected into every container session,
## resolved from sops secrets inside the VM.
# secretEnv.ANTHROPIC_API_KEY = "anthropic";
# vm = { cpus = 8; mem = 8192; diskSize = 40960; };
};Anything else NixOS supports is fair game -- it is your VM. Apply changes with
nixcage rebuild (this restarts the VM and interrupts running sessions).
The CLI finds the flake at ~/.config/nixcage; override with
--flake <ref> or NIXCAGE_FLAKE. Multi-machine setups export one
configuration per host: nixosConfigurations."nixcage-<hostname>" is
preferred over the shared nixosConfigurations.nixcage, so your dotfiles
flake can serve a macOS laptop and a NixOS desktop from one repo.
A project is any directory under a workspace root. There is no
nixcage init and no nixcage file in the repo -- devShells.default is the
entire interface, and it is optional: a directory with an .envrc is entered
through direnv, one with neither gets the base container shell. Want
claude-code in a project? Put it in that project's devShell (see
examples/project/). nixcage installs nothing into containers.
| Command | Description |
|---|---|
nixcage enter [--memory SIZE] [--cpus N] [--bind SRC:DST] [--bind-ro SRC:DST] [--setenv K=V] [--shell NAME] [--no-agent] [--print-argv] [--substrate nspawn|microvm] [--disk SIZE] [-- cmd] |
Enter this project's cage (on macOS, auto-starts the VM); with a command, run it non-interactively. The substrate is fixed at the first enter; --disk gives a microVM a persistent image at /var/lib. The options that parameterise a session for a dependant are reached with exec |
nixcage exec [--tty] [--agent] -- cmd |
Run a command as root where the cages are: on this host on Linux, inside the VM on macOS |
nixcage list [--json] |
What cages this machine has: name, substrate, whether it runs, the uid it is mapped onto. --json gives each cage's record as nixcage wrote it |
nixcage rm [name] |
Delete a project's container and persistent home |
nixcage status |
Configuration in use, containers, and on macOS the VM state and age public key |
nixcage down |
Stop the VM (macOS only) |
nixcage rebuild |
Rebuild from the config flake and restart the VM (macOS only) |
nixcage version |
Print the version |
nixcage runs cages and has no opinion about what runs in them. A tool that wants more than one person entering one project -- several named workers sharing a repository, say -- pins nixcage as a flake input and uses five exported primitives, which are the whole interface (ADR-009):
A --bind source is resolved where the cage runs: on Linux that is this host,
so any path works; on macOS it is the VM, which sees only your workspace
roots, so a source outside them is not there to bind.
| Primitive | What it gives |
|---|---|
nixcage-container enter [--uid n] [--user name] [--subject name] [--home path] [--shell name] [--bind SRC:DST] [--bind-ro SRC:DST] [--setenv K=V] [--no-agent] [--network BRIDGE:ADDR/PREFIX|ns:PATH] [--dns none|ADDR] [--no-nix-daemon] [--memory SIZE] [--cpus N] [--substrate nspawn|microvm] [--disk SIZE] [--print-argv] <name> <project> [cmd] |
A session built out of what you asked for, bounded on its scope when asked; with --print-argv, the nspawn or vmspawn line it would run, one word per line, and no session. On a microVM cage (ADR-019) --memory and --cpus are the guest's own, --shell and ns: are refused, and --network places a tap nixcage makes |
nixcage-container uid <principal> [<subject>] |
A durable uid for a name, never reissued |
nixcage-container storage ensure <path> <uid> [quota] |
That path, under /var/lib/nixcage, owned by that uid, bounded where it can be |
nixcage-container status <name>, netns <name>, stop <name> |
A running cage from its scope: its leader and cgroup, the namespace path enter --network ns: takes (none for a microVM, which has no namespace on the host), and an end to it (ADR-012) |
nixcage-container list [--json] |
Every cage entered and not removed; with --json, one object per cage with what enter was given (uid, subject, placement, roots) and, while it runs, its scope and leader (ADR-017) |
nixcage-container exec [--subject <name>] <name> [-- cmd] |
A command inside a running cage: its leader's namespaces, its HOME and PATH, as cage root or as a declared subject (ADR-012); over vsock ssh into a microVM cage, as the session's uid with the session's environment (ADR-019) |
nixcage-container plan --machine <name> [--auth-sock <path>] |
An enter or exec line a machine's guest proposed, read NUL-separated from stdin, validated and run on the host (ADR-027) |
nixcage exec [--tty] [--agent] [--machine <name>] -- <cmd> |
A way to reach the others from your own machine; with --machine, the root of a machine (ADR-026) |
nixcage-container machine up|down|status <name>, and --machine <name> first on any verb above but netns |
A machine's lifecycle, and the verb run by the machine's own nixcage-container (ADR-026) |
You name paths and principals; nixcage names datasets and numbers. Set
nixcage.principalUidRange to allow allocation at all, and
nixcage.principalSubjects when one cage needs to hold processes that should
not be able to reach each other: a principal is then allocated a contiguous
block, one uid per subject beside cage root, and a session may run as one of
them (ADR-010). Declaring no subjects is a block of one and a session that is
cage root, which is what ADR-004 built.
A bridge a cage is placed on with enter --network is declared as
nixcage.bridges.<name> = { address; prefix; } on either module (ADR-018):
nixcage renders the bridge with no static ports, its address, and the two
settings an empty bridge needs to be usable before its first cage arrives;
what a cage may reach on that address is your firewall's to open.
cageworks is built on exactly this.
On Linux, secrets come from the host's own sops-nix setup: declare
nixcage.secretEnv next to your existing sops.secrets and rebuild. The steps
below are the macOS path, where the VM owns the key.
Secrets go through sops-nix, declared in your config flake; the host environment is never read.
- Boot the VM once; an age key is generated on its data volume and never
leaves it.
nixcage statusprints the public key. - Add that key to
.sops.yamlnext to your config flake and encrypt asecrets.yamlwithsops. - Declare the secret and its mapping in the config flake:
sops.defaultSopsFile = ./secrets.yaml;
sops.secrets.anthropic = { };
nixcage.secretEnv.ANTHROPIC_API_KEY = "anthropic";nixcage rebuild. Every container session now has the variable set.
Interactive logins work too: container homes persist, so claude login done
once inside a container survives restarts.
The VM owns its store, and every project's devShell lands in the same writable overlay, so it only grows. The VM therefore collects garbage weekly, keeping 30 days, and hardlinks duplicate paths. Override any of it in your config flake:
nix.gc.dates = "monthly";
nix.gc.automatic = false;Collection between sessions drops devShell closures, since nix develop holds
only a temporary root; the next nixcage enter for that project fetches them
again. On Linux there is no VM and no separate store: the host's own gc policy
applies unchanged.
| Linux | macOS | |
|---|---|---|
| Containers run | on the host | in a shared VM |
| Host isolation | container boundary (nspawn), or a microVM per cage with --substrate microvm |
VM boundary + container |
| Nix store | host store, shared | VM-owned store |
| Hypervisor | -- | qemu (Apple HVF) |
| Secrets | host sops-nix | VM sops-nix + age key |
| Config | nixosModules.host in your NixOS config |
VM config flake |
The container environment is always Linux; on macOS, macOS-native binaries do not exist inside the VM.
nixcage rebuild compiles a NixOS system, which requires building
aarch64-linux derivations. macOS needs a Linux builder for that:
## nix-darwin
nix.linux-builder.enable = true;or, without nix-darwin, keep nix run nixpkgs#darwin.linux-builder running in
another terminal during the build.
1.x gave every project its own VM configured by nixcage.vm.nix. That model is
gone. In each old project: stop the VM, delete nixcage.vm.nix and
.nixcage-vm/, and drop the .nixcage-vm/ line from .gitignore. Remove the
shell hook block from your ~/.zshrc / ~/.bashrc -- 3.x has no hook. Then
set up the host module (Linux) or the config flake (macOS) as above.
Nothing carries over from a 1.x VM: the containers, their persistent homes, and the age key are all created fresh.
GPLv3