nix + bwrap + rstrict + $CLI = ${CLI}box!
Generalizes numtide's claudebox over an arbitrary CLI binary, so the same sandboxing wrapper serves multiple CLIs from one builder. Adds Landlock LSM integration (soon). Additional CLIs are welcome. Verified to work on NixOS, Ubuntu WSL, and MacOS (via Seatbelt).
Like numtide's claudebox, each CLI gets a generic NixOS environment with an isolated $HOME and...
./in read-write mode- (e.g.)
~/.claudein read-write mode /run/user/$UIDis hidden by default- parent directories hidden by default
claudebox— Claude Codecodexbox— OpenAI Codex CLIopencodebox— OpenCodehermesbox— Hermes Agentpibox— pi agent
If you are stuck installing bubblebox with this explanation, don't hesitate to email me.
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
devshell.url = "github:numtide/devshell";
flake-parts.url = "github:hercules-ci/flake-parts";
bubblebox.url = "github:nix-tools/bubblebox";
};
outputs = { nixpkgs, devshell, flake-parts, bubblebox, ... }@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
imports = [ devshell.flakeModule ];
perSystem = { pkgs, system, ... }: {
_module.args.pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
overlays = [ bubblebox.overlays.default ];
};
devshells.default.packages = [
pkgs.claudebox
pkgs.opencodebox
# pkgs.hermesbox
# pkgs.pibox
];
};
};
}Override mounts with --rw PATH / --ro PATH. Handy for working with git worktrees, or when you
want to add a program-specific directory from $HOME or elsewhere:
claudebox --rw ../worktree-base -- --continue # `git commit` works now
claudebox --ro ~/Downloads/assets -- -c # add extra out-of-band dir
claudebox --rw ~/Projects/another-thing # when you work on two things
claudebox --rw ~/.cargo # share Cargo package cache/run/user/$UID is hidden by default. These flags punch a specific hole through for tools that
need to reach a running agent:
claudebox --allow-ssh-agent # bind $SSH_AUTH_SOCK (e.g. `git push` over SSH)
claudebox --allow-gpg-agent # bind the GPG agent socket (e.g. signed commits)
claudebox --allow-dbus # bind the D-Bus session socket (e.g. OS keyring / Secret Service)
claudebox --allow-xdg-runtime # expose the whole XDG runtime dir, not just one socket--allow-dbus is a tighter alternative to --allow-xdg-runtime when a tool only needs the OS
keyring: it binds just the D-Bus session socket instead of the whole runtime dir.
See Profiles.
A profile has a named list of mounts baked into a box at build time and selected at runtime with --profile NAME. A profile named default is active when --profile is not given. Inspired by nono.sh profiles, but configured with Nix instead of runtime config files.
{ pkgs, ... }:
let
claudebox = pkgs.claudebox.override {
parentMounts = "parent"; # "none" (default) | "parent" | "tree"
profiles = {
# active without --profile; plain strings mean read-only,
# or use { path = "..."; mode = "rw"; } for read-write
default.mounts = [ ];
};
};
in
{
environment.systemPackages = [ claudebox ];
}Profile mounts that don't exist on the host are skipped.
CLI --rw/--ro flags override profile mounts on overlap.
Note that .override { profiles = ...; } replaces the box's whole profile set.
The parentMounts setting controls how much of the working tree's ancestry is bound read-only. It can be overridden per profile:
"none"(default): only./is mounted (read-write); parent directories appear empty"parent":../is also mounted, read-only, but any parents above that appear empty"tree": the deepest ancestor below$HOME(or/) is mounted read-only, e.g.~/Projectswhen standing in~/Projects/foo/bar
$HOME and / themselves are never mounted. Prior to profiles, boxes behaved like "tree"; the default is now the more conservative "none".
/etc/ssh is bound from a sanitized copy rather than the host directory: the copy is owned by the invoking user, and every Include line in it is commented out. OpenSSH fatally rejects a config file it reaches through Include unless the file is owned by root or by the invoking user, and bwrap maps only the invoking uid — so root-owned host config reads as nobody and ssh aborts before it ever looks at ~/.ssh. What the includes carry is host integration glue (systemd-ssh-proxy on NixOS, crypto policy elsewhere) that no bind can make pass that check while uid 0 is unmapped.
Reaching a remote still needs a way to authenticate, since $HOME is isolated:
claudebox --allow-ssh-agent # bind $SSH_AUTH_SOCK, e.g. for `git push`
claudebox --ro ~/.ssh # or hand over the keys themselves- apps for running without installing
- packages for installing into flakes
- overlays.default — for adding all programs to
pkgs
nix run github:nix-tools/bubblebox#claudebox
nix run github:nix-tools/bubblebox#codexbox
nix run github:nix-tools/bubblebox#opencodebox
nix run github:nix-tools/bubblebox#hermesbox
nix run github:nix-tools/bubblebox#piboxNote: To forward arguments to the wrapped CLI under
nix run, you need two--separators — the first endsnix run's own arguments, the second is consumed by the box and tells it to forward the rest:nix run github:nix-tools/bubblebox#claudebox -- -- --continue nix run github:nix-tools/bubblebox#claudebox -- --allow-ssh-agent -- --resume
Add an entry to the boxes attrset in nix/packages.nix:
mybox = {
tool = pkgs.my-cli;
toolBinary = "my-cli";
homeBindings = [ ".my-cli" ];
defaultArgs = [ ];
toolEnv = { };
description = "Sandboxed environment for my-cli";
};This produces the corresponding package, app, and overlay attribute
automatically. The builder is mkBubblebox in nix/bubblebox.nix.
{
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.bubblebox.url = "github:nix-tools/bubblebox";
inputs.bubblebox.inputs.nixpkgs.follows = "nixpkgs";
outputs = { self, nixpkgs, bubblebox, ... }: {
nixosConfigurations.example = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
({ pkgs, ... }: {
nixpkgs.overlays = [ bubblebox.overlays.default ];
environment.systemPackages = [
pkgs.claudebox
pkgs.opencodebox
pkgs.hermesbox
pkgs.pibox
];
})
];
};
};
}MIT.