Tool Mappings

The [tools] section maps tool names to container images. Local mappings take priority over the global tool index.

A project can also declare its own additive overlay — a default -e file, extra capabilities, dotfiles, env vars, even new alias names like python-dev/python-prod — in a .boksrc (YAML) at its root, without redefining what a tool means. See Project Config.

Simple format

[tools]
rg = "boks.sh/rg:latest"
jq = "ghcr.io/jqlang/jq:latest"

A bare string maps the tool name directly to an image. boks uses the image's default CMD or ENTRYPOINT.

Detailed format

[tools]
python = { image = "boks.sh/python:3.12", command = "python3" }
dig = { image = "boks.sh/dns:latest", command = "/usr/bin/dig" }
FieldRequiredDescription
imageone of image/aliasOCI image reference
aliasone of image/aliasAnother tool name this entry resolves through
commandnoBinary to run instead of the image's CMD/ENTRYPOINT
capabilitiesnoCapabilities to auto-enable: net, rw, browser, clipboard, pid
dotfilesnoHost dotfiles to mount read-write at /boks/home/<rel> — also the opt-in for a protected_dotfiles path (see below)
pass_envnoEnv-var glob patterns to pass through for this tool, additive to [global] pass_env — also the opt-in for a protected_env_vars name (see below)
dotenvnoProject .env files to read host-side and inject as env vars for this tool — the opt-in that pairs with the protected_project_files deny list (see below)
descriptionnoShort description shown in boks --search

Use command when:

  • Multiple tools share one image (e.g., dig, nslookup, and host from the dns image)
  • The image's default entrypoint is a wrapper script, not the tool binary

Capabilities

A tool entry can request the same escalations the command-line flags grant, so a tool that always needs them works without flags:

[tools]
mytool = { image = "example.org/me/mytool:latest", capabilities = ["net"] }

Each capability is only auto-enabled when you did not set the corresponding option explicitly on the command line (--cap ro still beats a rw capability).

Image-declared args and ports

A published image can declare per-subcommand default args and ports on itself — the same mechanism a project overlay uses, but baked into the image so every consumer gets the tool's sensible defaults with no .boksrc of their own. This is how boks jupyter lab auto-binds 0.0.0.0 and publishes port 8888, or boks zola serve auto-binds 0.0.0.0 and publishes 1111 — without any per-project config. It's declared in the tool's own build recipe (not something you set via [tools] in your own config) and shows up on the published image as the sh.boks.subcommand-args/sh.boks.subcommand-ports labels:

# in the tool's own build recipe (boks-containers/tools/jupyter/manifest.toml)
[args]
lab      = ["--ip", "0.0.0.0"]
notebook = ["--ip", "0.0.0.0"]

[ports]
lab      = ["8888"]
notebook = ["8888"]

Both args and ports are always keyed on args[0] here (the subcommand), so boks jupyter --version stays network-free — only lab/notebook/server get the port and the --ip 0.0.0.0 splice. This is different from a project overlay's own subcommand_index (see Project Config: Subcommands), which can key on a different argument position — a published tool's CLI grammar is fixed and known at build time (its subcommand always IS args[0]), but a project-specific interpreter+script shape (python manage.py runserver) often isn't, so that escape hatch exists only at the project layer, never here. A project overlay's args append to the image's defaults (so a project's own --ip 127.0.0.1 lands last and wins under last-wins parsing), and its ports replace the image's defaults, per subcommand key. See Project Config: Subcommands for the full merge semantics.

Dotfiles

A tool entry can declare host dotfiles to mount into the container at /boks/home/<rel> (mirroring where the container's HOME points), so persisted tool config survives across runs. Mounted read-write by default:

[tools]
gh = { image = "boks.sh/gh:latest", dotfiles = ["~/.config/gh"] }

Each entry is ~-expanded against $HOME. Use the structured form to control how a missing path is bootstrapped on first run — kind = "file" creates an empty regular file (correct for ~/.vimrc), kind = "dir" creates a directory (correct for ~/.aws):

[tools]
vim = { image = "boks.sh/vim:latest", dotfiles = [{ path = "~/.vimrc", kind = "file" }] }

A bare string is treated as kind = "dir" (the legacy form).

Read-only dotfiles

Add readonly = true to mount :ro instead of the default :rw — for paths a tool only ever needs to read, e.g. an ssh-only client that should never modify ~/.ssh, or a model cache an inference tool shouldn't be able to corrupt:

[tools]
ssh-tool = { image = "…", dotfiles = [{ path = "~/.ssh", kind = "dir", readonly = true }] }

The tool can read the mounted path but a write attempt fails (EROFS), and the host file is never touched either way. Omitting readonly (or a bare string entry) defaults to false — unchanged, existing behavior.

Opting into a protected dotfile

dotfiles is also the opt-in for the protected_dotfiles deny list. A path on that list (e.g. ~/.ssh) is access-denied inside containers by default; a tool that legitimately needs it declares it as a dotfile, which mounts the real path at /boks/.ssh for that tool only. The working-directory exposure at /boks/workdir/.ssh stays access-denied — opting in grants the /boks mount, not a license to read the secret through the CWD copy.

Combine with readonly = true for a tool that only ever needs to read the secret — an ssh/scp client never needs to modify your private key:

[global]
protected_dotfiles = ["~/.ssh", "~/.gnupg"]

[tools]
# A tool that genuinely needs to read ~/.ssh (e.g. an SSH client), but never
# to write it:
ssh-tool = { image = "…", dotfiles = [{ path = "~/.ssh", kind = "dir", readonly = true }] }

Env vars

A tool entry can declare its own env-var pass-through patterns, additive to [global] pass_env:

[tools]
mytool = { image = "example.org/me/mytool:latest", pass_env = ["MY_PROJECT_TOKEN"] }

Opting into a protected env var

pass_env is also the opt-in for the protected_env_vars deny list. A name on that list (e.g. AWS_SECRET_ACCESS_KEY) is never passed through by a [global] pass_env match alone, even a broad glob like AWS_*; a tool that legitimately needs it declares the exact name in its own pass_env, granting it for that tool only.

[global]
pass_env = ["AWS_*"]  # does NOT grant AWS_SECRET_ACCESS_KEY — it's protected

[tools]
aws-cli = { image = "…", pass_env = ["AWS_SECRET_ACCESS_KEY"] }

Injecting .env files

dotenv pairs with the protected_project_files deny list, which denies .env and its variants to every tool (the raw file can't be exfiltrated as bytes). A tool that declares dotenv instead gets the values injected as environment variables — read host-side — so .env "just works" the way it does for an ordinary CLI tool, without the file itself being readable:

[tools]
my-app = { image = "…", dotenv = [".env", ".env.local"] }

Vars already passed through (pass_env / the terminal passthrough) are not overridden — real-env-wins, standard dotenv convention. The opt-in bypasses protected_env_vars: a tool that declares dotenv = [".env"] explicitly asked for that file's contents, so a protected-named var present in .env (e.g. AWS_SECRET_ACCESS_KEY) is injected for that tool — the same contract as pass_env opting into a protected real-env var.

The curated index declares sensible defaults: node layers .env + .env.local, python/ruby/php get .env. A tool without dotenv (e.g. rg, cat) gets no injection and sees the denied (empty) file.

Aliases

An alias entry resolves another tool name through the normal chain (local config → index → fallback) and applies its own overrides on top. This is how you make named variants of a tool:

[tools]
python-net  = { alias = "python", capabilities = ["net"] }   # python, but with network
python-3.11 = { image = "boks.sh/python:3.11", command = "python3" }

Combined with shims, variants feel native:

boks --install python-net
python-net -c "import urllib.request; ..."   # containerized, network enabled
python -c "..."                              # still fully isolated

The alias's own command and capabilities win over its target's; aliases may not point at other aliases.

Tool groups

A [groups] table names a set of tools under one name, usable anywhere a composed tool can be named: -e's comma-separated compose segments, a project's .boksrc compose list, or a tool's own index-declared default_compose. A group is not itself a tool — it has no image/bin and can't be run directly (boks coreutils fails the same way any unknown name does); it only ever expands into other tools' compose entries.

[groups]
mytools = ["ls", "rg", "jq"]
boks -e mytools bash          # ls, rg, and jq all reachable on PATH inside bash

The tool index ships built-in groups too — coreutils (the full seccomp-confined coreutils set) is what bash's own default_compose uses, so a bare boks bash script.sh already has ls/cat/cp/sed/grep/... available without you naming anything. Your own [groups] entry of the same name shadows a built-in one — checked first.

Each group member must itself be a composable tool (same rule as an ordinary -e/compose entry — see Package managers); an unknown or non-composable member name fails with the normal compose-resolution error. Groups don't nest: naming another group inside a group is rejected outright, keeping a group a flat list rather than a tree.

Composing a group grants none of its members' own auto-capabilities, same as composing a single tool — boks -e mytools bash doesn't make bash's container read-write just because a member has write syscalls available; you still pass --cap rw yourself if you need it.

Resolution order

  1. Local boks.toml ([tools] section; aliases resolve through their target)
  2. Global tool index (cached at ~/.cache/boks/index/index.json)
  3. Fallback: boks.sh/<tool>:latest

Override a tool from the global index by adding it to your local config.