Security Layers
boks combines several container security mechanisms to enforce isolation. This page is the canonical reference for what boks enforces by default and how each layer is overridden.
Default restrictions
Every container boks launches (via Podman) starts with:
| Layer | Default | Override |
|---|---|---|
| Network | denied (--network=none) | --cap net (or force it off despite an image/tool default with --cap nonet) |
| Inbound ports | none published | --port <host:container> (implies --cap net) |
| Working directory | mounted :ro | --cap rw (or force :ro with --cap ro) |
| Image filesystem | read-only (--read-only) | --cap rwimg |
| Linux capabilities | --cap-drop=ALL, then a minimal set re-added (CAP_CHOWN, CAP_DAC_OVERRIDE) | --privileged |
| New privileges | denied (--security-opt=no-new-privileges) | --privileged |
| User namespace | --userns=keep-id:uid=N,gid=N — N is the image's own declared UID when resolvable, else 1000 | — |
This container-wide seccomp profile is generated at runtime but currently disabled on macOS (the Podman VM cannot read host temp paths). See "Per-binary confinement" below for a second, independent seccomp layer that isn't affected by that limitation.
Per-binary confinement (seccomp)
The container-wide profile above is a floor applied to the whole container. It doesn't, by itself, stop one composed tool's grant from leaking to another: -e lets you mount more than one tool into the same container (boks --cap net -e dig ls gives ls a live network namespace too, purely because it shares a container with dig — ls never asked for network and has no use for it). Dropping Linux capabilities doesn't close this gap either — an ordinary socket()/connect() call needs no capability, only a network namespace that already exists.
boks closes it at the binary level instead. Nearly every tool in the index is a composable image (see Core Concepts → Images from the index), and each one carries its own seccomp filter, compiled directly into the binary at build time and installed on itself before it execs. Because the filter travels with the binary rather than being applied to the container as a whole, it holds no matter what else is composed alongside it — and because it's baked in at build time rather than generated per-run, it isn't affected by the macOS limitation above; it works the same on every platform.
A binary's filter has two parts:
- A fixed baseline, applied to every composable binary unconditionally: no
setuid/setgid, noptrace, no mounting or namespace manipulation, no kernel module loading — syscalls no ordinary CLI tool ever legitimately needs, regardless of what capabilities the container itself was granted. - Named, opt-in tiers, declared per binary in the tool catalog and combinable:
readonly(the binary can never create, write to, or delete a file — verified forls/cat-shaped tools before being declared),spawn(it may fork a subprocess — a shell, a pager launcher),net-connect(it may make outbound connections but can never accept inbound ones — most network clients),net-listen(it may also bind and accept — something that actually serves). A binary gets only the tiers it was verified to need; most get none beyond the baseline.
A readonly-tier binary's inability to write isn't just the working-directory mount being :ro — the write syscalls themselves are refused for that binary, independent of --cap rw on the invocation. This is what makes composition safe by binary rather than by container: giving a bash session network access for one composed tool's sake doesn't hand it to every other tool riding along in the same container.
Network isolation
Containers run with --network=none by default. There is no network interface available.
boks curl https://example.com
# curl: (6) Could not resolve host: example.com
Grant network access when needed:
boks --cap net curl https://example.comFilesystem isolation
Only your current working directory is mounted into the container — at /boks/workdir. Your home directory, SSH keys, and everything else are not visible inside the container.
| Path inside container | Source | Mode |
|---|---|---|
/boks/workdir | Your current working directory | :ro (default) / :rw (--cap rw) |
~/.gitconfig | ~/.gitconfig on the host | :ro (if mount_git_config = true and git is present — primary, inline-package install, or composed) |
SSH_AUTH_SOCK | SSH agent socket | forwarded (if forward_ssh_agent = true) |
The working directory is read-only by default:
boks python -c "open('test.txt', 'w').write('hello')"
# PermissionError: [Errno 30] Read-only file system: 'test.txt'
Grant write access with --cap rw:
boks --cap rw python -c "open('test.txt', 'w').write('hello')"
The container image filesystem is also read-only (--read-only). Use --cap rwimg when a tool needs to write to paths inside its own image (e.g. /tmp, /var/cache):
boks --cap rwimg some-tool
--cap rwimg does not affect your host filesystem — it only allows the container to write to its own image filesystem.
Config self-protection
boks's own config and state, and the container runtime's, are never writable from inside a container — even under --cap rw and even when your working directory is your home directory.
These host directories are protected unconditionally (there is no opt-out flag):
| Directory | What it holds |
|---|---|
~/.config/boks | boks.toml — tool→image mappings, scan policy, security_scan_enabled |
~/.local/share/boks | security decisions — accepted scans, trusted projects |
~/.cache/boks | regenerable state — cached index.json, scan verdicts, freshness stamps, the grype database |
~/.config/containers | the runtime's connection/storage config |
~/.local/share/containers | the rootless image store |
~/.docker | the runtime's config |
All of this is state that controls how the next container run executes. If a tool could write to it — for example cd ~ && boks --cap rw python mounts your home directory read-write at /boks/workdir, exposing every one of those paths — it could rewrite tool mappings, disable scanning, forge a "scanned clean" verdict, poison the cached index, or repoint the runtime at a malicious socket. boks prevents this by layering a read-only shadow bind mount over each protected path wherever it would otherwise be writable:
cd ~ && boks --cap rw python -c "open('/boks/workdir/.config/boks/boks.toml','w').write('x')"
# PermissionError: [Errno 30] Read-only file system: '/boks/workdir/.config/boks/boks.toml'
A sibling write inside the workdir still succeeds — --cap rw itself is unaffected, only the protected paths are shadowed. The mechanism is a nested :ro bind over the :rw parent: writes fail with Read-only file system (EROFS), the parent stays writable, and host files are untouched (and cap-drop=ALL means the container can't remount it).
To edit boks.toml, use a host editor — editing it through a boks-run editor will fail with the error above. This is deliberate: the protection has no opt-out because the exploit vector is exactly "a tool you ran rewrote the rules for the next tool you run."
Sensitive dotfile protection
The config-self-protection above guards against writes (config poisoning). A separate mechanism guards against reads — secret exfiltration. Your SSH private keys, GPG keyring, and other secrets live under your home directory, and running boks from your home directory (cd ~ && boks …) mounts that whole directory at /boks/workdir, which would otherwise expose them.
boks ships a customizable deny list of sensitive dotfiles, protected by default:
# boks.toml — [global] section
[global]
protected_dotfiles = ["~/.ssh", "~/.gnupg"]
These are the defaults. Each entry is ~-expanded against $HOME and must be HOME-relative (start with ~/); a non-conforming entry is logged and skipped rather than aborting the run.
When a protected dotfile would be exposed via the working-directory mount — under either :ro or :rw (secret reads work read-only too) — boks layers a read-only tmpfs over the exposed container path. The path is visible but access-denied, not silently absent:
cd ~ && boks python -c "open('/boks/workdir/.ssh/id_rsa').read()"
# PermissionError: [Errno 13] Permission denied: '/boks/workdir/.ssh/id_rsa'
The tmpfs is owned by root (mode 0700), while the container process runs as uid 1000 (--userns=keep-id). The directory itself exists (os.path.exists('/boks/workdir/.ssh') → True), but the container's uid lacks the directory's search/execute permission, so anything under it — files, open(), further stat() calls — fails EACCES ("Permission denied") at the syscall level. Note that Python's own os.path.exists() swallows every OSError (including EACCES) and reports False for a path inside the denied directory, indistinguishable from a genuinely missing file at that specific check — it's open()'s exception type (PermissionError vs FileNotFoundError) that carries the real distinction. Surfacing a real "Permission denied" on open() — rather than a confusing "no such file" — gives tools and users an honest error and makes the protection legible: the secret is there, the tool just can't have it.
Per-tool opt-in
A tool that legitimately needs a protected path opts in by declaring it as a dotfile (see Tool mappings). The path is then mounted read-write at /boks/home/<rel> for that tool:
[tools]
my-tool = { image = "…", dotfiles = [{ path = "~/.ssh", kind = "dir" }] }
The opt-in grants the /boks/.ssh mount only — the working-directory exposure at /boks/workdir/.ssh stays access-denied. Opting into a path is not a license to read the secret through the CWD copy.
Opting out
The deny list is fully user-customizable. To remove a default (re-expose a path everywhere), omit it; to extend the list, add entries:
[global]
# Remove all defaults — ~/.ssh and ~/.gnupg are re-exposed:
protected_dotfiles = []
# Or extend, e.g. also protect cloud credentials:
protected_dotfiles = ["~/.ssh", "~/.gnupg", "~/.aws"]Protected environment variables
The env-var analog of sensitive dotfile protection. By default, containers receive no host env vars at all; [global] pass_env opts specific variables in via glob patterns (see Environment Variables). A broad pattern like pass_env = ["AWS_*"] is convenient, but should not be able to silently hand a container AWS_SECRET_ACCESS_KEY along with the harmless variables it was meant for.
boks ships a customizable deny list of well-known secret-bearing variable names, protected by default:
# boks.toml — [global] section
[global]
protected_env_vars = [
"AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN",
"GITHUB_TOKEN", "GH_TOKEN", "NPM_TOKEN", "DOCKER_PASSWORD",
]
These are the defaults. Unlike protected dotfiles, there is no mount to shadow — a protected variable is simply never passed. A [global] pass_env entry naming it directly (not just a glob) does not grant it either; only a tool's own pass_env opts in.
Per-tool opt-in
A tool that legitimately needs a protected variable opts in by declaring it in its own pass_env (see Tool mappings):
[tools]
aws-cli = { image = "…", pass_env = ["AWS_SECRET_ACCESS_KEY"] }
The opt-in applies to that tool only — every other tool still gets the variable filtered out even under a matching [global] pass_env glob.
Opting out
The deny list is fully user-customizable, the same way as protected_dotfiles:
[global]
# Disable the deny list entirely:
protected_env_vars = []
# Or extend it, e.g. also protect a project-specific secret:
protected_env_vars = ["AWS_SECRET_ACCESS_KEY", "MY_PROJECT_SIGNING_KEY"]Project file (.env) protection
The CWD-relative analog of sensitive dotfile protection. .env is the cross-ecosystem convention for local secrets — .env, .env.local, .env.production, and the direnv .envrc live in your project tree, and the working-directory mount would otherwise expose them as raw bytes (boks cat .env, boks rg SECRET). boks ships a customizable deny list, protected by default:
# boks.toml — [global] section
[global]
protected_project_files = [".env", ".env.*", ".envrc"]
These are the defaults. Patterns match file names directly under the CWD only, not subdirectories — .env.* catches .env.local in the CWD, but not subdir/.env.local. Run boks from subdir itself for that file to be protected. Entries must be CWD-relative (no leading / or ~/); a non-conforming entry is logged and skipped.
When a matched file would be exposed via the working-directory mount — under :ro or :rw — boks layers a read-only bind of an empty file over the exposed container path. (A tmpfs can't shadow a file — it errors "Not a directory" — so this uses a :ro bind, the same mechanism as config self-protection.) The denied file exists but reads empty:
echo "SECRET=hello" > .env
boks python -c "print(open('/boks/workdir/.env').read())"
# (empty — the secret bytes never reached the container)
boks python -c "open('/boks/workdir/.env','w').write('x')"
# PermissionError: [Errno 30] Read-only file system: '/boks/workdir/.env'
Writes fail EROFS (Read-only file system), and the host file is untouched — even under --cap rw. This is a different denial semantic from sensitive dotfile protection above (dirs → visible-but-EACCES via tmpfs): a denied project file is present but empty, because tmpfs can't shadow a file. The security property is the same: the secret bytes never reach the container.
Per-tool opt-in (inject the values)
Denying the raw file is only half of ".env just works" — the tool also needs the values. A tool that declares dotenv gets them injected as environment variables (read host-side), the way .env works for an ordinary CLI tool:
[tools]
my-tool = { image = "…", dotenv = [".env", ".env.local"] }
The vars are injected with real-env-wins: a variable already passed through (pass_env or the terminal passthrough) is not overridden by the file. The dotenv opt-in bypasses protected_env_vars filtering — a tool declaring dotenv = [".env"] explicitly asked for that file's contents, exactly as a pass_env entry opts into a protected real-env var, so a protected-named var like AWS_SECRET_ACCESS_KEY present in .env is injected for that tool. Meanwhile the raw file stays denied to every tool (injection doesn't un-deny the file).
The curated tool mappings declare sensible dotenv defaults: node layers .env + .env.local (the Vite/CRA convention), python/ruby/php get .env. A tool without a dotenv declaration (e.g. rg, cat) gets no injection — it sees the denied (empty) file like any other tool.
.envrc is deny-only
.envrc is a direnv shell script, not a KEY=VALUE file, so it's on the deny list but in no tool's default dotenv — it's deny-only by default. boks doesn't special-case it in the parser; if a tool explicitly lists it in dotenv, the parser handles the export KEY=VALUE lines it can. The deny-only default is what keeps it out of the injection path.
Opting out
[global]
# Disable file denial entirely (re-expose .env to every tool):
protected_project_files = []
# Or extend, e.g. also protect a secrets.yml:
protected_project_files = [".env", ".env.*", ".envrc", "secrets.yml"]Per-project config (.boksrc)
A project can commit a .boksrc (YAML) declaring an additive invocation overlay for tools it names — a default -e file, extra capabilities, dotfiles, env vars (pass_env from the host, or set_env authored directly), subcommand-scoped extra CLI arguments, subcommand-scoped ports to publish, project-scoped package_rules, and project-scoped aliases (python-dev/python-prod, minting a new name rather than overlaying an existing one). It cannot redefine what an existing tool is: there is no image or command field on a plain overlay entry, so a cloned project can never swap the image or binary a tool name resolves to — that stays exclusively your own boks.toml and the global index. See Configuration → Project Config for the full field reference.
Unconditional denial
Independent of everything below, .boksrc is always denied to every container — no [global] opt-out exists, the same unconditional protection ~/.config/boks already has. A tool can never read the raw file (so it can't learn a secret-bearing field a future version might add) or rewrite it (so it can't grant itself more access on the project's next invocation):
boks python -c "print(repr(open('.boksrc').read()))"
# '' -- reads back empty, not the real overlay bytes
boks --cap rw python -c "open('.boksrc', 'w').write('pwned')"
# OSError: [Errno 30] Read-only file system: '.boksrc'
The mechanism is the same empty-file :ro bind-mount .env/protected_project_files use — an unwritable empty file shadows the real one, so reads return empty bytes and writes fail closed, for a .boksrc directly under the CWD only (a monorepo's nested packages/foo/.boksrc is not protected when running from the repo root; run boks from packages/foo itself instead).
Trust-on-first-use
.boksrc arrives via git clone/git pull/a PR — fundamentally less trusted than your own config, which only changes when you edit it yourself. git clone some-project && boks python stays exactly as inert as it is without this feature at all, until a human reviews and trusts what the project declares. The first run in a project with an unreviewed (or since-edited) .boksrc prompts, SSH-host-key style:
▍ ■ SECURITY .boksrc declares a project overlay ▍ python: -e requirements.txt, +net ▍ ■ INPUT Apply this project's overlay? ▍ [ y ] yes [ n ] no [ o ] once ▍ ←/→ move ⏎ pick or press y n o
y applies it and remembers this exact file content — no further prompts for this project until .boksrc's bytes change, at which point it silently reverts to asking; o applies the overlay for this run only (asks again next time); n (or anything else) declines (asks again next time). Without a terminal to ask on, a prompt that would otherwise fire fails closed to "no", the same posture as the vulnerability scanner's own review gate — matching that gate's own y(always)/o(once) letter convention exactly.
The review isn't limited to the overlay's own literal fields: trusting env_file means trusting whatever that file installs, and some packages (e.g. requests) auto-grant capabilities like net through boks's package-rules mechanism. The prompt shows that too, tagged (via <file>) for a global-catalog match, or (via .boksrc package_rules) for a grant coming from the project's own declared rules — see Configuration → Project Config for the full example.
Capability dropping
Containers run with --cap-drop=ALL, then a minimal capability set is re-added so common tools work without giving away dangerous privileges. --privileged removes the restriction entirely for tools that genuinely require it:
boks --privileged some-network-tool
Use --privileged sparingly — it disables the capability and no-new-privileges protections.
GPU device passthrough
Off by default. --cap gpu grants CDI (Container Device Interface) access to the host's GPU — a genuine attack-surface expansion (the container gets a real device node, not just a namespace flag), so it's opt-in only, never image-label-driven:
boks --cap gpu ollama run llama3
Requires the host's NVIDIA/AMD CDI setup to already exist (e.g. nvidia-ctk cdi generate) — boks does not configure it, only passes --device nvidia.com/gpu=all through to the runtime. Podman (4.1+) and Docker (25+, experimental) both support this.
An index entry or package rule can also request it automatically (e.g. a tool that always needs a GPU, or installing torch triggering it) — the same auto-enable-unless-overridden precedence as the other --cap values applies.
Raw sockets (--cap netraw)
Off by default. --cap netraw adds CAP_NET_RAW, which is what ICMP and
packet-level tools need — ping, nmap -sS, tcpdump:
boks --cap netraw nmap -sS scanme.nmap.org
It grants one capability and nothing else. The container still has only its
own network namespace, so tcpdump sees the container's traffic, not the
host's. Watching host traffic means host networking (--network=host), which
removes network isolation altogether — a far larger grant than one capability,
and deliberately not bundled in here.
Unlike gpu, this one is image-label-driven (sh.boks.security.netraw),
because the tools that need it do not work at all without it: a ping that
cannot open an ICMP socket is not a safe default, it is a broken tool. boks ping 1.1.1.1 therefore works with no flag, the same way curl's image declares
its own network need. Tools that do not declare it still get nothing.
Note for rootless setups: the net.ipv4.ping_group_range sysctl is often
suggested as an alternative, and does not work here. Podman rejects the usual
wide range inside a rootless user namespace ("are all the IDs mapped in the
user namespace?"), and even a mapped range leaves iputils' ping selecting a
raw socket. --privileged does not help either — it bypasses boks's own
capability handling and inherits the runtime's rootless defaults, which exclude
NET_RAW.
No new privileges
--security-opt=no-new-privileges prevents a process inside the container from gaining additional privileges through setuid binaries or other mechanisms.
User namespaces
Podman's --userns=keep-id:uid=N,gid=N maps whichever host user invoked podman into the container's new user namespace, appearing there as uid/gid N — not root. The process is unprivileged both inside the container (uid N, not 0) and on the host (still just your own user), so it has no special access to host resources.
N defaults to 1000 — the uid every boks-published image bakes in — but boks reads the image's own declared USER (via .Config.User on podman inspect) and uses that instead when it resolves to a real, non-root numeric uid. This matters for third-party images whose own baked-in files (a database's data directory, a service account's home) are chowned to a different uid than 1000; forcing them to uid 1000 regardless would cause permission errors trying to read the image's own content. An image with no declared USER, or one that resolves to root (empty, "0", or "root"), still maps to 1000 — boks never silently runs a container as root just because that's the image's own default; that stays the explicit opt-in --cap rwimg already provides. A declared USER that's a name rather than a number (e.g. USER app) also falls back to 1000, since resolving a name to a uid would need reading /etc/passwd from inside the image — not attempted currently. This is the reason boks's own images (USER boks) still map to 1000 as before: the fallback happens to be exactly right for them.
Docker has no keep-id equivalent at all, so this only applies to the Podman runtime — under Docker, a container simply runs as whatever USER the image itself declares (root, if none). A separate mechanism overrides /etc/passwd//etc/group inside the container to show the real host username/group by name (not the generic boks every published image bakes in) on both runtimes — this only fixes the displayed name, not the underlying uid/gid mismatch on Docker.
Container lifetime
Containers are ephemeral. Once the tool exits, the container is removed (--rm). Nothing persists between runs except the image cache.
Ctrl-C
Every container runs with --init (podman/docker): a tiny init process becomes the container's PID 1 instead of the tool itself, and forwards signals to it. Without this, a tool that runs directly as PID 1 gets Linux's init-process signal semantics — SIGINT (Ctrl-C) and SIGTERM are silently dropped unless the tool explicitly installs its own handler. Most interactive tools do install one (Python, curl, ping all handle their own cleanup on Ctrl-C), but simple ones — sleep, cat, tail -f — don't, and would otherwise ignore Ctrl-C entirely and run to completion.
--init is skipped when --cap pid shares the host's PID namespace (used by htop/ps/pgrep/etc.): podman refuses to combine the two ("cannot add init binary as PID 1" — there's no private PID namespace for the init to occupy). Tools using --cap pid typically handle their own signals anyway.