Project Config

boks.toml (your own config, at ~/.config/boks/boks.toml) controls what a tool is — its image, its command. A project's .boksrc controls how your already-resolved tools run in that project, and can mint brand-new named variants of them — an additive overlay, never a redefinition of what an existing name already means.

.boksrc is a single YAML file, committed at the project root — no directory, no second file (mirrors .bazelrc/.eslintrc-style tooling config).

What a project can declare

# .boksrc, committed at the root of a project
tools:
  python:
    env_file: requirements.txt
    capabilities: [net]

  cmake:
    capabilities: [net, rw]
FieldDescription
aliasNames a real, already-resolvable tool this entry expands from — mints a brand-new tool NAME (python-dev, python-prod), rather than overlaying an existing one. See Aliases.
env_fileDefault environment file for this tool in this project — a bare boks python main.py behaves as if -e @pip:requirements.txt were passed. An explicit CLI -e always wins.
capabilitiesCapabilities this project needs for this tool (net, rw, browser, clipboard, pid, gpu), additive to whatever your own config/index already grants.
dotfilesProject-scoped dotfiles, additive to the tool's own (same shape as ToolConfig::dotfiles).
pass_envProject-scoped env-var patterns, additive to the tool's own — forwards a value already present in your shell.
set_envFixed env vars authored directly here — distinct from pass_env. See set_env.
dotenvProject-scoped dotenv files, additive to the tool's own.
composeOther tools' binaries to compose into this tool's container, additive to -e's composed-tool segments (same shape as Tool groups's member list).
subcommand_indexWhich args[N] the subcommands map below matches against — default 0. See Subcommands.
subcommandsPer-subcommand extra args and published ports, keyed by subcommand name. See Subcommands.

There is deliberately no image or command field on a plain overlay entry — a project cannot redefine what an existing name like python or cmake means. That stays exclusively yours to decide, in your own boks.toml and the global index, project or no project. A project author who tries image: ... here gets a loud parse error, not silent ignoring. alias doesn't reopen this: it only ever mints a genuinely new name, and that name's target must itself resolve through the normal chain — see Aliases for the full boundary.

Top-level, sibling to tools, a project can also declare its own package_rules — see Project-scoped package_rules.

Direct binary execution (boks <path>)

Any tool-name argument containing a / is unambiguously a request to run a local file directly, never a tool-name lookup — boks tool names never contain /. boks target/debug/hello (or boks ./hello, boks ../sibling/hello, boks /abs/path/hello) validates the file is a real ELF binary, then executes it directly against boks-base-static-ubuntu — boks's own minimal base, not a raw upstream image (see Tool Mappings for why arbitrary upstream images aren't supported at all). A bare name with no / anywhere (even one that happens to match a local file) still resolves as an ordinary tool-name lookup — write ./name to mean "the local file," the same convention a shell already uses to distinguish a PATH-resolved command from a CWD-local one.

The tools: map's key isn't limited to a tool name — it's matched as an exact string against whatever boks sees as its tool-name argument, so a /-containing path works as a key too, granting that directly-executed binary the same capabilities/pass_env/set_env/dotenv an ordinary tool overlay entry can:

tools:
  target/release/my-server:
    capabilities: [net, rw]

The match is exact and unnormalized — target/release/my-server and ./target/release/my-server are two different keys, so the key here has to match however your own scripts actually invoke the binary.

A path key is not a second-class citizen: capabilities, dotfiles, pass_env, set_env, dotenv, compose, and subcommand_index/subcommands all apply to it exactly as they do to a named tool. A compiled binary has its own argv like anything else, so a per-subcommand port and arg splice work the same way:

tools:
  ./hello:
    capabilities: [rw]
    subcommands:
      serve:
        ports: ["8080"]
        args: ["-addr", "0.0.0.0:8080"]

boks ./hello serve publishes the port and gets the bind address; boks ./hello gets neither. The trust prompt describes it the same way it describes a named tool (./hello: +rw, port(serve) 8080, args(serve) -addr 0.0.0.0:8080).

Only env_file and alias genuinely have no meaning here — there's no package-manager environment to build for a raw ELF binary, and no tool name to alias.

Aliases

An alias entry mints a new tool name — a project-scoped variant of an existing tool, bundling env/capabilities/ports together under one name, the same idea as ~/.config/boks/boks.toml's own alias field (python-net = { alias = "python", capabilities = ["net"] }) but committed with the project and trust-gated instead of typed into your own machine-wide config:

tools:
  python-dev:
    alias: python
    env_file: requirements-dev.txt
    capabilities: [net, rw]
    set_env:
      FLASK_ENV: development

  python-prod:
    alias: python
    env_file: requirements.txt
    set_env:
      FLASK_ENV: production

Once trusted:

boks python-dev app.py    # dev deps, net+rw, FLASK_ENV=development
boks python-prod app.py   # prod deps, FLASK_ENV=production
boks python app.py        # still exactly what it always was, untouched

Layering against the target, same rule ~/.config/boks/boks.toml's own aliases already follow: single-value fields (env_file, subcommand_index) override the target's own if both declare one; list/map fields (capabilities, dotfiles, pass_env, set_env, dotenv, compose, subcommands) are additive to whatever the target already resolves to.

The collision rule: an alias only takes effect when its name doesn't already resolve via your local config or the global index. If a project declared python-dev and that somehow collided with a real tool name, the alias is inert — the real tool always wins, unconditionally. A project can mint a genuinely new name; it can never shadow or redefine an existing one. This is the same invariant the missing image/command fields protect above, just extended to cover the case where the "existing thing" is a name rather than a tool's own identity.

The alias's own target must resolve through the normal, non-project-controlled chain (local config → global index) — not through another project alias. No chaining.

Subcommands

subcommands is keyed on the tool's own subcommand — the argument at position subcommand_index in the tool's own trailing args (default 0, its first trailing argument) — rather than applying unconditionally. This matters: a dev server like zola serve needs its port published and bound to all interfaces, but zola build/zola check never asked for that network access.

tools:
  zola:
    subcommands:
      serve:
        ports: ["2222"]                       # publish 2222 instead of the image's 1111
        args: ["--interface", "127.0.0.1"]    # bind loopback instead of 0.0.0.0

A published tool's own image can declare these same per-subcommand args/ports as a base default (see Tool Mappings), so boks zola serve and boks jupyter lab "just work" with no project config at all. A project overlay is for per-project overrides on top of those defaults — args appends to the image's own default args (so your own explicit flag still wins under the tool's own last-wins CLI parsing — almost every parser, zola's included, takes the last occurrence of a repeated option), ports replaces the image's own default ports for that subcommand key (a different port is a different server, not additive).

ports behaves like the image-label sh.boks.ports mechanism — publishing implies network — except scoped to your project and to the one subcommand that actually needs it. boks never inspects what a spliced args token means; it's a plain sequence splice, not argument parsing.

subcommand_index: when the subcommand isn't args[0]

A tool invoked directly (zola serve, jupyter lab) has its subcommand at args[0] — the default, and correct for every published tool's own image-declared defaults (see above; those stay args[0]-only, since a published tool's CLI grammar is fixed and known at build time). But a project-specific "interpreter runs a script that has its own subcommand grammar" shape often doesn't: python manage.py runserver's real subcommand is args[1]args[0] is always the literal string "manage.py", never runserver/test/migrate. Declare subcommand_index to shift where subcommands looks:

tools:
  django-python:
    alias: python
    env_file: requirements.txt
    subcommand_index: 1
    subcommands:
      runserver:
        ports: ["8000"]
        args: ["0.0.0.0:8000"]

boks django-python manage.py runserver gets the port and bind-address; boks django-python manage.py test and ... migrate get neither — real per-subcommand scoping, not the flat "grants the same thing to every subcommand" a bare args[0] match on "manage.py" could ever offer.

A key with no matching subcommand at all (a typo, or the tool invoked with too few trailing args to reach subcommand_index) is silently inert, exactly as if the entry didn't exist.

set_env

Fixed env vars authored directly in .boksrc, distinct from pass_env (which forwards a value already present in your own shell) and dotenv (which reads a file host-side):

tools:
  python-prod:
    alias: python
    set_env:
      FLASK_ENV: production
      LOG_LEVEL: warning

set_env wins over both pass_env and dotenv for the same key — it's the most explicit statement in the whole file, an intentional override, not a fallback default.

Project-scoped package_rules

boks-index/policy.toml's global package_rules (the mechanism that auto-grants net when a project installs flask/django/requests/etc.) is index-curated — appropriate for widely-used public packages, but a real gap for an internal or niche one you'd never file a catalog PR for. A project can declare its own, top-level, sibling to tools:

package_rules:
  - provider: pip
    package: my-internal-pkg
    capabilities: [net]

tools:
  python:
    env_file: requirements.txt

Same shape the global catalog uses (provider/package/capabilities/dotfiles) — additive only, matched the same way, applied automatically whenever that package shows up in an env_file-driven build for this project. A project can grant through this mechanism; it can never mute a global entry (consistent with everything else in .boksrc: escalation-only, no relaxation).

Trust-on-first-use

.boksrc arrives however the project does — git clone, git pull, a PR — so it is not trusted by default. The first time you run a tool in a project that declares one, boks shows you exactly what it grants and asks:

▍  ■ SECURITY   .boksrc declares a project overlay
▍               cmake: +net, +rw
▍               python: -e requirements.txt, +net
▍  ■ INPUT      Apply this project's overlay?
▍               [ y ] yes    [ n ] no    [ o ] once
▍               ←/→ move    ⏎ pick    or press y n o

Entries are sorted, so the same file always reviews the same way. The sort is over the whole rendered line rather than the tool name, which is why a variant like python-dev lists before the bare python it aliases (- sorts before the : that ends the shorter name).

  • y — apply it AND remember this exact content. No more prompts for this project, until .boksrc changes — at which point it silently reverts to asking again.
  • n (or Esc, or Ctrl-C) — decline. Runs exactly as if .boksrc didn't exist. Asks again next time.
  • o — apply it for this run only. Asks again next time.

(This mirrors the vulnerability scanner's own y=always/o=once convention exactly — the same "the more consequential choice gets the obvious key" reasoning applies to both review gates in boks.)

The review isn't limited to the overlay's own fields

Trusting env_file means trusting whatever that file installs — some packages (e.g. requests, httpx, aiohttp, or anything matched by this project's own package_rules) auto-grant net through boks's package-rules mechanism, the same one that lets a bare boks python:requests reach the network with no --cap net. If a project's env_file would trigger that, the review shows it, tagged with exactly which source caused it — even when the overlay's own capabilities field says nothing about it:

▍               python: -e requirements.txt, +net (via requirements.txt)

The (via <file>) tag means the GLOBAL catalog matched a package this file installs. A grant coming from the project's own declared package_rules instead is tagged distinctly — (via .boksrc package_rules) — so a reviewer can tell "the global catalog decided this" from "this project's own author decided this," genuinely different trust weight for a same-shaped grant:

▍               python: -e requirements.txt, +net (via .boksrc package_rules)

Without a terminal to ask on (CI, piped stdio, --non-interactive), the prompt fails closed to "no" — the same posture as the vulnerability scanner's own review gate.

git clone some-project && boks python is exactly as inert as it is without this feature at all, until you've reviewed and trusted what the project asks for.

.boksrc is always denied to containers

Independent of trust state, .boksrc itself is never readable or writable from inside a container — no opt-out exists, the same unconditional protection ~/.config/boks already has. A tool can't read the raw config file, and can't rewrite it to grant itself more on the next invocation:

boks python -c "open('.boksrc').read()"
# ''  (reads empty — the real bytes never reach the container)
boks --cap rw python -c "open('.boksrc', 'w').write('pwned')"
# OSError: [Errno 30] Read-only file system: '.boksrc'

This applies only to a .boksrc directly under the CWD, not nested subdirectories — a monorepo's packages/foo/.boksrc is not protected when running from the repo root. Run boks from packages/foo itself for that package's .boksrc to apply.

See Security Layers → Per-project config for the full mechanism.