claudebox/.planning/research/STACK.md
Christopher Mühl 6465da8583 feat(04-01): add credential file mount for OAuth passthrough
- Add CREDS_FILE/CREDS_MOUNT detection after mkdir ~/.claudebox
- Conditional --bind in exec bwrap via BWRAP_ARGS array
- Mirror conditional bind in --dry-run display block
- Read-write mount (not ro-bind) for OAuth token refresh
- Silent skip when credentials file absent (no error/warning)
- Refactor exec bwrap to BWRAP_ARGS array for conditional mount support
2026-04-10 09:20:18 +00:00

13 KiB

Technology Stack

Project: claudebox Researched: 2026-04-09 Note: Research conducted from training data only (web/shell tools unavailable). Versions should be verified against current nixpkgs before implementation.

Core: Nix Derivation via writeShellApplication

Technology Version Purpose Why Confidence
writeShellApplication nixpkgs stable Produce the claudebox wrapper script Generates a shellcheck-validated bash script in the Nix store with runtime PATH wired to declared runtimeInputs. Superior to writeShellScriptBin because it runs shellcheck at build time and sets set -euo pipefail automatically. HIGH
bubblewrap (bwrap) 0.9.x+ Sandbox runtime Unprivileged user-namespace sandbox. In nixpkgs as bubblewrap. No setuid needed on NixOS (user namespaces enabled by default). HIGH
claude-code CLI The wrapped tool Provided by Anthropic's npm/standalone installer. Assumed pre-installed or passed as input. HIGH

Runtime Dependencies (runtimeInputs)

Package Purpose Why This One Confidence
bubblewrap Sandbox The whole point HIGH
coreutils Basic shell utils env, cat, echo, mkdir, etc. HIGH
git Version control Claude Code requires git for repo operations HIGH
curl HTTP requests Claude Code's MCP and tool use HIGH
jq JSON processing Env audit display, config manipulation HIGH
ripgrep Search Claude Code's preferred grep HIGH
fd File finding Claude Code's preferred find HIGH
nix Package manager Required for nix shell inside sandbox HIGH
comma On-demand packages Runs nix shell nixpkgs#<pkg> -c <cmd> via , <cmd> syntax HIGH
nix-index Package database Required by comma to resolve command -> package mapping HIGH
bash Shell bwrap needs a shell to exec into HIGH
nodejs Runtime Claude Code is a Node.js application HIGH

NOT in runtimeInputs (Important)

Package Why Excluded
gnupg Secret material -- explicitly hidden
openssh Secret material -- explicitly hidden
age / agenix Secret material -- explicitly hidden
tailscale Infrastructure access -- explicitly hidden

Key Nix Functions

writeShellApplication -- Use This

pkgs.writeShellApplication {
  name = "claudebox";
  runtimeInputs = with pkgs; [
    bubblewrap coreutils git curl jq ripgrep fd
    nix comma nix-index bash nodejs
  ];
  text = ''
    # Script body here -- bwrap invocation
    # PATH is automatically set to include all runtimeInputs
    # set -euo pipefail is automatic
    # shellcheck runs at build time
  '';
}

Confidence: HIGH -- this is the standard nixpkgs pattern for wrapper scripts.

Why NOT These Alternatives

Alternative Why Not
writeShellScriptBin No shellcheck, no automatic set -euo pipefail, no runtimeInputs wiring. You'd have to manually construct PATH.
makeWrapper / wrapProgram Designed for wrapping existing binaries with env vars/flags. Overkill and wrong abstraction -- we're writing a new script, not patching an existing binary.
symlinkJoin + makeWrapper Pattern for combining multiple derivations. Not needed -- we have one script.
stdenv.mkDerivation Too heavy. writeShellApplication is a specialized shortcut for exactly this use case.
runCommand / writeScript Lower-level, no shellcheck, no runtimeInputs.

Bubblewrap Flags

Namespace Isolation

bwrap \
  --unshare-user        # New user namespace (maps to root inside)
  --unshare-pid         # New PID namespace (can't see host PIDs)
  --unshare-ipc         # New IPC namespace
  --unshare-uts         # New UTS namespace (hostname isolation)
  # NOTE: --unshare-net is OUT OF SCOPE per project constraints
  # NOTE: --unshare-cgroup usually unnecessary for this use case

Confidence: HIGH -- these are standard bwrap namespace flags.

Filesystem Mounts

bwrap \
  # Empty root
  --tmpfs /                            # Start with empty tmpfs root

  # Nix store (read-only, required for nix/comma)
  --ro-bind /nix/store /nix/store      # All Nix packages
  --ro-bind /nix/var /nix/var          # Nix DB (needed for nix commands)

  # System essentials
  --ro-bind /etc/resolv.conf /etc/resolv.conf  # DNS resolution
  --ro-bind /etc/ssl /etc/ssl          # TLS certificates
  --ro-bind /etc/nix /etc/nix          # Nix config (substituters, etc.)
  --proc /proc                         # /proc filesystem
  --dev /dev                           # Device nodes

  # Working directory (read-write)
  --bind "$PWD" "$PWD"                 # CWD mounted read-write

  # Claude config (remapped)
  --bind "$HOME/.claudebox" "$HOME/.claude"  # Persistent config

  # Home directory structure
  --tmpfs "$HOME"                      # Empty home
  --bind "$PWD" "$PWD"                 # Re-bind CWD after home tmpfs

  # Temp
  --tmpfs /tmp                         # Writable tmp
  --tmpfs /run                         # Writable run

Confidence: HIGH for the pattern, MEDIUM for exact mount ordering (test on NixOS to confirm).

Mount Ordering Matters

bwrap processes mounts in order. Later mounts can overlay earlier ones. The correct order is:

  1. --tmpfs / (empty root)
  2. --ro-bind /nix/store (packages)
  3. --tmpfs $HOME (empty home)
  4. --bind $HOME/.claudebox $HOME/.claude (config into home)
  5. --bind $PWD $PWD (working directory -- after home tmpfs if CWD is under HOME)

Confidence: HIGH -- this is well-documented bwrap behavior.

Environment Handling

bwrap \
  --clearenv                           # Start with empty environment
  --setenv HOME "$HOME"                # Explicit home
  --setenv PATH "$SANDBOX_PATH"        # Controlled PATH
  --setenv TERM "$TERM"                # Terminal type
  --setenv LANG "$LANG"                # Locale
  --setenv EDITOR "nano"               # Safe editor (no vim with plugin configs)
  --setenv USER "$USER"                # Username
  --setenv SHELL "/bin/bash"           # Shell
  --setenv NIX_PATH "$NIX_PATH"        # For nix-env/comma
  --setenv XDG_CACHE_HOME "$HOME/.cache"
  --setenv TMPDIR /tmp

--clearenv is the critical flag. This implements the allowlist model: start empty, add explicitly. Without it, you're doing denylist (trying to --unsetenv secrets you know about -- guaranteed to miss some).

Confidence: HIGH

Process Execution

bwrap \
  --die-with-parent                    # Kill sandbox if parent dies
  --new-session                        # New session (prevents tty hijacking)
  -- \
  claude --dangerously-skip-permissions "$@"

Confidence: HIGH

Comma and nix-index

How Comma Works

comma (the , command) is a wrapper that:

  1. Takes a command name (e.g., , python3)
  2. Looks up which nixpkgs package provides that command using nix-index database
  3. Runs nix shell nixpkgs#<package> -c <command> [args...]

Packaging in nixpkgs

  • pkgs.comma -- the comma binary itself
  • pkgs.nix-index -- the indexer that builds/queries the database
  • Database: comma needs a pre-built index. Two options:
    1. nix-index-database (flake from nix-community) -- pre-built weekly index, no local indexing needed. This is what you want.
    2. nix-index --update -- builds index locally, takes 30+ minutes. Don't do this.

For claudebox

The nix-index database needs to be available inside the sandbox. Options:

  • Mount ~/.cache/nix-index read-only into the sandbox (if using nix-index-database on the host)
  • Or use the nix-index-database flake's comma-with-db package which bundles the database

Recommendation: Use comma-with-db from nix-index-database flake if available, otherwise mount the host's nix-index database read-only.

Confidence: MEDIUM -- comma-with-db packaging may have changed. Verify against current nix-community/nix-index-database flake.

Flake Structure

{
  description = "claudebox - sandboxed Claude Code wrapper";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    # Optional: pre-built nix-index database
    nix-index-database = {
      url = "github:nix-community/nix-index-database";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, nix-index-database, ... }:
    let
      system = "x86_64-linux";
      pkgs = nixpkgs.legacyPackages.${system};
    in {
      packages.${system}.default = pkgs.writeShellApplication {
        name = "claudebox";
        runtimeInputs = with pkgs; [
          bubblewrap coreutils git curl jq ripgrep fd
          nix bash nodejs
          # comma with bundled database from nix-index-database flake
        ];
        text = builtins.readFile ./claudebox.sh;
      };

      # Or as an overlay for integration with host NixOS config
      overlays.default = final: prev: {
        claudebox = self.packages.${final.system}.default;
      };
    };
}

Confidence: HIGH for the pattern, MEDIUM for nix-index-database integration specifics.

Why builtins.readFile for the Script Body

Keep the shell script in a separate claudebox.sh file rather than inline in Nix. Reasons:

  • Shell syntax highlighting in editors
  • Shellcheck can run independently
  • Easier to iterate on the script without touching Nix expressions
  • writeShellApplication still runs shellcheck on it at build time

Confidence: HIGH

PATH Construction Inside Sandbox

The sandbox PATH should only contain Nix store paths. writeShellApplication handles this for the wrapper script itself, but the PATH inside the bwrap sandbox needs to be constructed explicitly:

SANDBOX_PATH=""
for pkg in ${coreutils} ${git} ${curl} ${jq} ${ripgrep} ${fd} ${nix} ${comma} ${bash} ${nodejs}; do
  SANDBOX_PATH="$SANDBOX_PATH:$pkg/bin"
done
SANDBOX_PATH="${SANDBOX_PATH#:}"  # Remove leading colon

Or more idiomatically in Nix, construct the PATH in the Nix expression and interpolate it into the script:

let
  sandboxPath = lib.makeBinPath [
    pkgs.coreutils pkgs.git pkgs.curl pkgs.jq
    pkgs.ripgrep pkgs.fd pkgs.nix pkgs.comma
    pkgs.bash pkgs.nodejs
  ];
in
pkgs.writeShellApplication {
  text = ''
    SANDBOX_PATH="${sandboxPath}"
    # ... bwrap --setenv PATH "$SANDBOX_PATH" ...
  '';
}

Use lib.makeBinPath -- it joins /nix/store/.../bin paths with colons. This is the standard nixpkgs function for constructing PATH.

Confidence: HIGH

Nix Store Access Inside Sandbox

For nix shell and comma to work inside the sandbox, you need:

  1. /nix/store mounted read-only (packages)
  2. /nix/var/nix mounted -- needed for nix commands to find the database
  3. Nix daemon socket: /nix/var/nix/daemon-socket/socket -- needed for nix shell to trigger builds/downloads
  4. NIX_PATH or registry config for nixpkgs resolution

The daemon socket is critical and easy to miss. Without it, nix shell fails because it can't talk to the Nix daemon.

--ro-bind /nix/store /nix/store
--ro-bind /nix/var/nix/db /nix/var/nix/db
--ro-bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket

Wait -- the daemon socket needs to be bind-mounted (not ro-bind) because it's a Unix socket:

--bind /nix/var/nix/daemon-socket /nix/var/nix/daemon-socket

Confidence: MEDIUM -- socket bind-mount behavior should be tested. The daemon socket may need --bind not --ro-bind.

Testing Strategy

Since this is a shell script wrapped in Nix:

  1. Build test: nix build succeeds (shellcheck passes)
  2. Smoke test: claudebox launches, env inside shows only allowlisted vars
  3. Secret test: Verify ls ~/.ssh fails, cat /etc/shadow fails, env | grep -i key returns nothing
  4. Comma test: , python3 --version works inside sandbox
  5. Mount test: Can write to CWD, cannot write outside CWD

No test framework needed. A simple test.sh with assertions suffices.

Confidence: HIGH

Sources

  • Training data knowledge of nixpkgs writeShellApplication (stable API since 2022)
  • Training data knowledge of bubblewrap (stable API, project is mature)
  • Training data knowledge of comma/nix-index-database (nix-community project)
  • All versions should be verified against current nixpkgs before implementation

Verification Checklist (for implementation phase)

  • Confirm bubblewrap version in current nixpkgs channel
  • Confirm comma and nix-index-database flake are current and compatible
  • Test Nix daemon socket access through bwrap bind mount
  • Test mount ordering with CWD under $HOME
  • Confirm --clearenv + --setenv pattern works with Claude Code (it may need vars we haven't listed)
  • Check if Claude Code needs ~/.local or ~/.config beyond ~/.claude