Nix for AI agent sandboxes

In this post I'll go through how I use Nix in Pact, my own agent sandboxing harness. Pact starts coding agents inside ephemeral Docker containers. Those agents need an easy way to install all manner of things (CLI tools, build tools, libraries, etc.).

Why Nix?

I've been running NixOS on my Linux workstation and MacBook (via nix-darwin) since 2021. My configuration is public on my GitHub. More recently all of my side-projects use devenv to provide consistent development environments regardless of the computer I use.

Nix felt like a natural fit when I started working on Pact. I also had a suspicion that given Nix's installation model, I could share a /nix/store between multiple running Docker containers.

Nix has an experimental feature called Flakes, which I use to declare development environments. Here's an example of a basic shell which makes a package available.

{
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
  outputs = { nixpkgs, ... }: 
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
    in
      {
        devShells.${system}.default = pkgs.mkShell {
          packages = [pkgs.hello];
        };
      };
}

Enter this shell with nix develop and inside the hello binary will be available. This is a simple example. Pact uses Nix to provide its base toolset, including ripgrep, coreutils, git and other utilities.

Building the agent sandbox

For the underlying base Docker image I chose Ubuntu with a Nix installation. Why didn't I use a Nix image? Mostly for compatibility. Some of the projects I work on make assumptions that they are being built/developed on Ubuntu systems. Using Ubuntu as the base system and then installing Nix gave me the best of both worlds.

Below is a simplified Dockerfile that Pact runs. See the original here.

FROM ubuntu:26.04@sha256:da6fc2be547864451aa253836dd926da33623312df4a9a243e35dc877c378a78

ARG TARGETARCH

RUN apt-get update && \
    apt-get install -y --no-install-recommends nix-bin

COPY nix.conf /etc/nix/nix.conf
COPY entrypoint.sh /opt/pact/entrypoint.sh
COPY env/ /opt/pact/toolset/

ENTRYPOINT ["/opt/pact/entrypoint.sh"]

The entrypoint script is very simple: it sets up a separate user account (not shown here) followed by entering the Nix shell.

exec nix shell path:/opt/pact/toolset#pact-tools --no-update-lock-file --command "$@"

The flake which makes this all possible looks like this:

{
  outputs = { nixpkgs, ... }: {
    packages = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] (
      system:
      let
        pkgs = import nixpkgs { inherit system; };
        tools = pkgs.buildEnv {
          name = "pact-tools";
          paths = with pkgs; [
            bashInteractive
            coreutils
            curl
            ripgrep
            # and others
          ];
        };
      in
        {
          pact-tools = tools;
        }
    );
  };
}

The flake exposes pact-tools, an environment made up of symlinks to packages in the Nix store. The full flake.nix is on GitHub.

Shared /nix/store

Giving every agent its own Nix store would mean keeping multiple copies of the same tools. Pact shares one store between them, with a single daemon handling builds and downloads.

The nix-daemon comes from Nix's multi-user installs. The daemon has write access to /nix/store, clients send it package requests, the daemon downloads or builds them and makes the result available in the store.

In Pact's Docker use case I create a long-lived volume for the /nix/store. This volume is mounted read-only in every agent container and writable in a single Nix daemon container.


_, err = docker.CreateContainer(ctx, docker.ContainerConfig{
  Name: "pact-nix",
  Image: "pact-nix",
  Mounts: []docker.Mount{
    {Type: "volume", Source: nix.Volume, Target: "/nix"},
  },
})

The agent containers get access to the daemon by virtue of having the store mounted at /nix. The daemon opens a socket at /nix/var/nix/daemon-socket/socket so by mounting the whole volume, every agent gets access without any extra work.

func configureStorage(nix Nix, config *docker.ContainerConfig) {
  config.Mounts = append(config.Mounts, docker.Mount{
    Type: "volume", 
    Source: nix.Volume,
    Target: "/nix",
    ReadOnly: true,
    NoCopy: true,
  })
  config.Env = append(config.Env, "NIX_REMOTE=daemon")
}

This setup allows containers to share package installs through the mounted Docker volume. So even if I have 10 agents all installing go, if they all want the same version of the package it will only be downloaded once.

With this I have ephemeral Docker containers for coding agents that provide isolation from the host system while caching and sharing any Nix packages that they might install to achieve their tasks.

Back to blog