Skip to content

Configuration

Every knob the sandbox honours, what it does, what it defaults to, whether an admin can lock it down, and a runnable example. Edit ~/.config/agent-sandbox/sandbox.conf (deployed automatically on first run); changes take effect the next time you start a sandbox — no reinstall.

How config is loaded

1. Defaults                                     (sandbox-lib.sh, hard-coded)
2. Admin baseline   /app/lib/agent-sandbox/sandbox.conf   (if present, root-owned)
3. User config      ~/.config/agent-sandbox/sandbox.conf  (or user.conf when admin baseline exists)
4. Per-project      ~/.config/agent-sandbox/conf.d/*.conf (sourced in lexical order)

Each layer adds to the previous. The admin layer (when present) is the security baseline: certain values land in the layer-2 snapshot and the user's layer-¾ entries are merged on top such that the user can append but not remove. See admin enforcement below for the exact rules.

SANDBOX_CONF (environment) points the user layer (3) at a different file. It only swaps that one layer: the admin baseline (2) and conf.d (4) still load, and admin enforcement still applies to whatever the file contains.

Launch overrides win over every config layer. --backend / SANDBOX_BACKEND, and the env vars PRIVATE_TMP, PRIVATE_IPC, FILTER_PASSWD, NETWORK_FILTER_MODE, NETWORK_FILTER_FALLBACK, NETWORK_MAIL_BLOCK, MOUNT_GUARD, MOUNT_GUARD_INTERVAL, HOME_ACCESS, SANDBOX_QUIET, SANDBOX_NPROC_LIMIT and SLURM_SCOPE, are applied after conf.d, so neither sandbox.conf nor a per-project file can override an explicit launch choice. An admin pin still beats them (the value is restored with a WARNING: Launch override (env/CLI) weakened … line). Config consistency checks (e.g. the HOME_READONLY/HOME_WRITABLE overlap warning, the BIND_DEV_PTS notice, the Landlock feature warnings) run on this final configuration.

User config is loaded in an isolated subprocess (no eval in the parent), then variable values are extracted via a three-layer-validated declare -p round-trip. Configs cannot mutate the sandbox via shell-level side effects — set, trap DEBUG, IFS=, exports, eval overrides, background jobs, etc. all stay inside the subprocess and are dropped. Unknown variable names produce a one-line warning at startup so stale config from older versions surfaces instead of silently breaking.

Admin enforcement model

When /app/lib/agent-sandbox/sandbox.conf exists, it is sourced first as a trusted file, then snapshotted. The user's layer-¾ config runs in the isolated subprocess described above, and after extraction the policy enforcer (_enforce_admin_policy in sandbox-lib.sh) merges per these rules:

Enforced arrays — admin entries are restored, user entries are appended on top. User cannot remove an admin entry; attempting it produces a WARNING: removed admin-enforced X entry '…' — restored. line on stderr.

Array Why locked down
BLOCKED_FILES Admin can pin specific files (e.g. site-wide instruction files) hidden from agents.
BLOCKED_ENV_VARS Site-wide env-var blocklist.
BLOCKED_ENV_PATTERNS Site-wide credential-pattern globs.
EXTRA_BLOCKED_PATHS Site-wide path blocklist (e.g. clinical data, regulated datasets).
DEVICES_BLACKLIST Site-wide device-node blocklist (e.g. /dev/pts to refuse the kernel-<6.2 TIOCSTI workaround).
HIDE_FROM_SANDBOX Host-side setting names kept out of the sandbox. (Its built-in defaults are a floor even without an admin baseline.)
NETWORK_BLOCKLIST Site-wide network-filter blocklist.
NETWORK_BLOCKLIST_EXCEPT Admin exceptions stay; user exceptions covered by an admin NETWORK_BLOCKLIST entry are stripped.

This set is exactly _ENFORCED_ARRAYS in sandbox-lib.sh; the enforcer iterates that list.

Enforced scalars (security-critical booleans) — admin can set to true and the user cannot weaken to false. Attempting it produces WARNING: weakened admin-enforced X=true → restored. and the value is restored.

Scalar Effect when admin sets true
PRIVATE_TMP /tmp isolated per sandbox (locked on).
PRIVATE_IPC IPC namespace isolated per sandbox (locked on).
FILTER_PASSWD LDAP/AD user enumeration filtered (locked on).

The tri-valued network scalars (NETWORK_FILTER_MODE, NETWORK_FILTER_FALLBACK, NETWORK_MAIL_BLOCK) and MOUNT_GUARD (off < warn < repair < kill; enforced only when the admin file sets it) are harden-only as well: user config, conf.d and launch-time env overrides may only request an equal or stricter value than the admin pin.

None of these can be changed through SANDBOX_ENV: it applies to the sandboxed command only and rejects config-variable names.

HOME_READONLY → writable escalation prevented — a user HOME_WRITABLE or EXTRA_WRITABLE_PATHS entry is reverted with a warning when, after canonicalization, it is equal to, inside, or a parent of an admin HOME_READONLY entry. Both the lexically normalized path (./.ssh, .ssh/, .ssh// are all .ssh) and the symlink-resolved path are compared, so a symlink alias (mylink -> .ssh) is caught too. Examples with admin HOME_READONLY=(".ssh" ".config/git"): .ssh/, .ssh/authorized_keys, .config, and EXTRA_WRITABLE_PATHS+=("$HOME/.ssh") are all reverted.

DENIED_WRITABLE_PATHS — admin-only deny-list (no user-side equivalent). Any user EXTRA_WRITABLE_PATHS or HOME_WRITABLE entry that resolves under a denied path is stripped with a warning. Symlinks are resolved on both sides so a writable path can't bypass the blocklist by pointing at a denied target.

ALLOWED_PROJECT_PARENTS (narrowing-only) — the user can only narrow admin's list, never expand it. A user-supplied path is admissible iff its canonical resolution (via realpath) is identical to or a path-component subdir of the canonical resolution of one of admin's allowed parents. Symlinks are followed: a user path that resolves outside admin's tree is rejected, even if its literal string appears under an admin entry. Inadmissible entries are stripped with a WARNING. If the merge yields an empty effective list (e.g., user requested only paths outside admin's tree), the sandbox refuses to start.

If admin doesn't set ALLOWED_PROJECT_PARENTS, the narrowing baseline is / (the user's list passes through unchanged). If admin sets a malformed value (non-array, non-absolute path, command substitution), the sandbox refuses to start. Missing admin file vs. malformed admin file is an explicit security boundary: missing → default to /; malformed → fail-closed (no fall-through).

Without an admin baseline, ~/.config/agent-sandbox/sandbox.conf is the only config and there is no enforcement layer — the user's configuration is the effective policy in full. See Admin Install for setting up an admin baseline.

Variables index

Variable Type Admin-enforced? Default
ALLOWED_PROJECT_PARENTS array narrowing-only (user can only restrict admin's list) /fh/fast, /fh/scratch, $HOME
READONLY_MOUNTS array additive merge system paths + /app
HOME_ACCESS scalar no tmpwrite
HOME_READONLY array RO→WR escalation blocked shell + tool defaults
HOME_WRITABLE array additive; may not equal, contain or sit inside an admin RO entry; respects DENIED_WRITABLE_PATHS .cache/uv + agent profile additions
HOME_SEEDED_FILES array additive .gitconfig
EXTRA_BLOCKED_PATHS array yes (admin entries restored) ()
EXTRA_WRITABLE_PATHS array additive; respects DENIED_WRITABLE_PATHS and admin HOME_READONLY ()
DENIED_WRITABLE_PATHS array admin-only ()
BLOCKED_FILES array yes () + per-agent additions
DEVICES array additive; vetoed by DEVICES_BLACKLIST NVIDIA driver nodes
DEVICES_BLACKLIST array yes /dev/{mem,kmem,port,pts,sd*,nvme*,loop*}
BIND_DEV_PTS scalar no (deprecated, kernel-aware shim) false
BLOCKED_ENV_VARS array yes service-credential names
BLOCKED_ENV_PATTERNS array yes SSH_*, *_TOKEN, *_SECRET, …
ALLOWED_ENV_VARS array additive agent API-key names
HIDE_FROM_SANDBOX array yes host-side setting names (SLURM_SCOPE, CHAPERON_LOG_*, SANDBOX_QUIET, …)
SANDBOX_ENV array additive; child-only, cannot set config/launcher names ()
SANDBOX_BACKEND scalar no auto (bwrap → firejail → landlock)
SANDBOX_PREFERRED_BACKENDS (set inline via SANDBOX_BACKEND only) — —
SANDBOX_MODULES array additive ()
PRIVATE_TMP scalar harden-only (admin true is sticky) true
PRIVATE_IPC scalar harden-only true
FILTER_PASSWD scalar harden-only true
SANDBOX_NPROC_LIMIT scalar no "" (unlimited)
MOUNT_GUARD scalar harden-only when set by the admin (off < warn < repair < kill) repair
MOUNT_GUARD_INTERVAL scalar no 5 (seconds)
MOUNT_GUARD_REBIND scalar no true
SANDBOX_QUIET scalar no false
NETWORK_FILTER_MODE scalar harden-only (user can only request equal or stricter) filtered
NETWORK_FILTER_FALLBACK scalar harden-only open
NETWORK_BLOCKLIST array yes (admin entries floor) built-in floor (SMTP submission, webhook/paste/DoH exfil surfaces, legacy r-services)
NETWORK_BLOCKLIST_EXCEPT array additive; user entries covered by an admin BLOCKLIST pattern are stripped ()
NETWORK_MAIL_BLOCK scalar harden-only (user can only request equal or stricter; off < auto < on) auto
NETWORK_FILTER_SKIP_HELPER_PROBE env override no 0
SLURM_SCOPE scalar no project
CHAPERON_LOG_LEVEL scalar no info
CHAPERON_LOG_RETAIN_DAYS scalar no 7
ENABLED_AGENTS array additive claude, codex, gemini
SUPPRESS_AGENT_WARNINGS array additive ()

Project & home

ALLOWED_PROJECT_PARENTS

Type array · Admin-enforced narrowing-only (user can only restrict admin's list) · Default ("/fh/fast" "/fh/scratch" "$HOME")

The sandbox grants write access to exactly one directory — the project dir, set with --project-dir or defaulting to $PWD. For safety, that path must resolve under one of the entries listed here. A project dir outside this set is rejected at sandbox start.

Set this to the parents under which projects live on your system. On non-Fred-Hutch hosts the defaults can be replaced (+= to append, plain = to replace).

ALLOWED_PROJECT_PARENTS=(
    "$HOME"
    "/data/myorg"
    "/scratch/myorg"
)

Admin/user merge — narrowing-only. When an admin baseline is present, the user can only restrict the admin's allow-list. A user-supplied entry is admissible iff its canonical path (via realpath, with all symlinks followed) is identical to or a path-component subdir of the canonical path of one of admin's entries. The check is canonical-on-canonical, so a user-supplied path whose canonical form escapes admin's tree (via a symlink that looks admissible) is rejected.

The path-component boundary is enforced: /foo is not a parent of /foobar. Each non-admissible entry is stripped from the effective list with a WARNING: line on stderr. If every user-requested entry is rejected (or the user's array is empty), the sandbox refuses to start — there is no fall-through to a permissive default.

Missing vs. malformed admin config. If the admin file is missing entirely, the narrowing baseline defaults to / (no narrowing — the user's list passes through unchanged). If the admin file is present but malformed — syntax error, runtime error during source, ALLOWED_PROJECT_PARENTS is not an indexed array, an entry is not absolute, or an entry contains command substitution — the sandbox refuses to start with a clear error. The boundary is explicit and security-relevant: missing admin → default; malformed admin → fail-closed.

HOME_ACCESS

Type scalar · Admin-enforced no · Default tmpwrite

Controls how much of $HOME the agent sees and whether unlisted writes persist. Override per-session via HOME_ACCESS=read agent-sandbox bash.

Mode Real files visible? Agent can write? Writes persist? Use case
tmpwrite (default) Only listed paths Anywhere in $HOME No — lost on exit Recommended. Agents create lock files, caches, temp dirs without errors; nothing leaks.
restricted Only listed paths Only HOME_WRITABLE + project Yes Maximum lockdown. Unlisted writes return EROFS.
read Everything Only HOME_WRITABLE + project Yes Agent needs to read arbitrary dotfiles or configs.
write Everything Everything Yes Full access — use with caution.

Credential dirs (.ssh, .aws, .gnupg) are always blocked, regardless of mode.

The sandbox's own config dir, ~/.config/agent-sandbox (sandbox.conf/user.conf, conf.d/, agents/ templates, the per-launch filtered passwd/group files), is always read-only inside the sandbox, regardless of mode, so the agent cannot weaken its next launch. It is applied after every writable grant, so HOME_ACCESS=write, HOME_WRITABLE+=(".config"), the project dir or an EXTRA_WRITABLE_PATHS entry cannot re-expose it (a symlinked config dir is protected at both the link and its target). This is not configurable; edit the config from outside the sandbox. The Landlock backend cannot carve a read-only sub-path out of a writable grant and warns instead.

In read and write mode the real $HOME is visible, so further credential stores are masked as well (bwrap: tmpfs over directories, /dev/null over files; firejail: blacklisted): .netrc, .git-credentials, .config/gh, .config/hub, .docker/config.json, .kube, .config/gcloud, .azure, .config/op, .config/helm, .terraform.d, .vault-token, .pgpass, .pypirc, .cargo/credentials(.toml). In tmpwrite/restricted mode these are already invisible unless you list them. To keep one visible in read/write mode, list it verbatim in HOME_READONLY (or HOME_WRITABLE), exactly as you would in tmpwrite mode, e.g. HOME_READONLY+=(".config/gh"); .ssh, .aws and .gnupg cannot be opted in this way. The Landlock backend cannot hide any sub-path of a granted $HOME (additive rules) and warns instead.

HOME_READONLY

Type array · Admin-enforced RO→WR escalation blocked · Default shell + tool config dotfiles (.bashrc, .zshrc, .vimrc, .tmux.conf, .linuxbrew, .local/bin, micromamba, .condarc, …)

Subdirectories and files of $HOME to mount read-only inside the sandbox. Each entry is $HOME-relative (no leading /). Missing entries are silently skipped, so listing dotfiles you might not have is safe.

Per-agent read-only entries (e.g. .aider.conf.yml) are folded in automatically from each enabled agent profile — see ENABLED_AGENTS.

HOME_READONLY+=(
    ".config/gh"        # GitHub CLI config (also add GITHUB_TOKEN to ALLOWED_ENV_VARS)
    ".config/nvim"      # neovim config
)

HOME_WRITABLE

Type array · Admin-enforced additive; admin HOME_READONLY entries cannot move here; respects DENIED_WRITABLE_PATHS · Default (.cache/uv) + per-agent additions from enabled profiles

Subdirectories and files of $HOME with read+write access. Missing entries are auto-created as empty dirs before the sandbox launches, so first-time in-sandbox auth works for agents.

If the admin baseline lists an entry in HOME_READONLY, the user cannot make it writable — not by listing it here under another spelling (./.ssh, .ssh/), not via a parent (.config over admin .config/git) or child (.ssh/authorized_keys), and not via a symlink alias. The sandbox warns and reverts (see admin enforcement).

HOME_WRITABLE+=(
    ".cache"
    ".my_tool_state"
)

HOME_SEEDED_FILES

Type array · Admin-enforced additive · Default (.gitconfig)

Files whose content is read from the host but materialised into the per-session tmpfs $HOME as a writable copy. The agent can edit them without touching the real host file — writes land in the tmpfs and are discarded on sandbox exit. Use this for dotfiles tools want to write but you don't want the agent to mutate persistently (gh auth setup-git rewriting .gitconfig, IDE git plugins, package-manager telemetry).

Conflict rule: an entry in HOME_SEEDED_FILES wins over the same entry in HOME_READONLY (the read-only mount is skipped).

Backend support:

  • bwrap — full support via --file FD DEST (writable tmpfs copy).
  • firejail — degrades to read-only with a startup warning.
  • Landlock — degrades to read-only with a startup warning (no mount namespace).
HOME_SEEDED_FILES=(
    ".gitconfig"
    ".npmrc"
    ".yarnrc"
)

Mounts

READONLY_MOUNTS

Type array · Admin-enforced additive merge · Default ("/usr" "/lib" "/lib64" "/bin" "/sbin" "/etc" "/app")

Directories the agent can read but never write. The system paths are required for basic functionality (the sandbox warns if they are missing on the host). Add data directories the agent needs to read.

The agent cannot access anything not listed here, so apply the principle of least privilege: mount only what the task needs. Mounting an entire lab share is convenient but exposes everything under it.

READONLY_MOUNTS+=(
    "/fh/fast/mylab/user/me"            # just your user dir — recommended
    "/fh/fast/shared/reference_genomes" # site-wide reference data
)

EXTRA_WRITABLE_PATHS

Type array · Admin-enforced additive; entries under DENIED_WRITABLE_PATHS are stripped with a warning · Default ()

Directories the agent can read and write in addition to the project directory. Use for shared output directories, pipeline scratch space, or other locations the agent must modify but that aren't the project dir. Each entry expands the agent's write surface — only add directories the agent genuinely needs.

EXTRA_WRITABLE_PATHS=(
    "/fh/scratch/delete30/mylab/agent-output"
)

Entries that are $HOME itself or one of its parents (e.g. /home, /) are ignored with a warning: a writable bind that covers $HOME would undo the home isolation (credential masks, protected agent config). Protective overlays (BLOCKED_FILES, EXTRA_BLOCKED_PATHS, the read-only agent config files, .sandbox-state/, the sandbox install dir) always win over an EXTRA_WRITABLE_PATHS entry that contains them. The same $HOME-or-parent rule applies to the project directory.

DENIED_WRITABLE_PATHS

Type array · Admin-enforced admin-only (no user-side counterpart; the variable is editable in user config but only the admin snapshot is honoured) · Default ()

Paths that must never be writable, regardless of user config. After the user/project layers are merged, any EXTRA_WRITABLE_PATHS or HOME_WRITABLE entry that resolves under a denied path is stripped with a warning. Both literal strings and resolved symlink targets are checked, so a writable entry pointing a symlink at a denied target is rejected.

Set this in the admin baseline only; user-set values do not survive admin enforcement.

# /app/lib/agent-sandbox/sandbox.conf (admin)
DENIED_WRITABLE_PATHS=(
    "/etc"
    "/usr"
    "/fh/fast/restricted_clinical"
)

EXTRA_BLOCKED_PATHS

Type array · Admin-enforced yes · Default ()

Paths outside $HOME that should be hidden inside the sandbox. Each path is overlaid with an empty tmpfs (bwrap/firejail) — the path appears to exist but resolves to an empty directory. Use to carve sensitive subdirectories out of otherwise-visible mounts (e.g. clinical data under a lab storage path).

EXTRA_BLOCKED_PATHS=(
    "/fh/fast/setty_m/restricted_clinical_data"
)

BLOCKED_FILES

Type array · Admin-enforced yes · Default () (per-agent instruction files added automatically)

Specific files inside readable or writable directories that should be hidden. Each file is overlaid with /dev/null — it appears to exist but is empty. Useful when a parent directory must be accessible but a specific file within it should be protected.

Per-agent instruction files (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, etc.) are added automatically by _apply_agent_profiles from each enabled agent's config.conf. The agent's overlay then exports a *_CONFIG_DIR env var so the agent reads the sandbox-merged copy instead — see agents/<name>/config.conf for the schema.

Backend limitation: only respected by bwrap and firejail. Landlock cannot block individual files under directories it has already granted access to (no mount namespace, no overlays).

bwrap masking needs a real file on host. When the bwrap backend implements BLOCKED_FILES via --ro-bind /dev/null <path>, the host path must already exist as a regular file: bwrap's ensure_file → create_file → creat() (see utils.c in v0.11.0) opens the target with O_CREAT during mount setup. If the target's parent is a writable host path, bwrap creates a zero-byte stub on host before binding /dev/null over it; if intermediate parent directories are missing, bwrap creates those too. The stub persists after sandbox exit unless explicitly removed. Firejail's --blacklist does not have this requirement (the path may be absent at launch). Landlock does not enforce BLOCKED_FILES at all — file-level masking needs a mount namespace, which Landlock doesn't have.

To make masking visible and controllable, sandbox-exec.sh materializes every missing BLOCKED_FILES entry itself at config-load (after _apply_agent_profiles, before backend_prepare):

  • mkdir -p "$(dirname X)" for any missing parents,
  • touch X to create a zero-byte placeholder under user-controlled permissions,
  • one WARNING: line per materialized path on stderr — and one per parent directory it had to create — so you can see exactly what host-side state the launcher touched.

If the materialization fails (non-writable parent like /etc, read-only mount), sandbox-exec.sh refuses to start and prints the full list of failing entries with a remediation hint. Silent skip would leave the path unenforced — the failure case is unrecoverable without user action, so we surface it.

Pass --cleanup-materialized (or set CLEANUP_MATERIALIZED_BLOCKED_FILES=1 in the environment or config) to have the launcher remove the placeholders post-exit. Cleanup is conservative: a file is removed only if it's still 0 bytes; a directory is removed only if it's still empty. Anything that grew (a real edit between launch and exit) is retained with a kept note on stderr.

Placeholders are also kept while another sandbox might have them mounted: always while another agent-sandbox session runs on the same host, and always on a network filesystem (NFS, Lustre, GPFS, ...), where sandboxes on other hosts (for example sandbox-wrapped Slurm jobs) cannot be seen. Deleting a file that is a mount point in another sandbox detaches that mount there and unmasks the path.

To opt out of the protection for a specific entry, remove it from BLOCKED_FILES. To make an entry permanently exist (no warning on each launch), run mkdir -p "$(dirname X)" && touch X once outside the sandbox.

BLOCKED_FILES+=(
    "$HOME/notes/secret.md"
)

Devices

DEVICES

Type array · Admin-enforced additive; vetoed by DEVICES_BLACKLIST · Default

DEVICES=(
    /dev/nvidia*
    /dev/nvidia-uvm
    /dev/nvidia-uvm-tools
    /dev/nvidia-modeset
    /dev/nvidiactl
)

Device nodes to expose inside the sandbox. Per-node on bwrap: each entry is bind-mounted via bwrap --dev-bind PATH PATH after bwrap --dev /dev has set up the minimal devtmpfs. firejail gives the sandbox a private /dev (--private-dev) and can only carry its own device classes into it (/dev/nvidia0–9, nvidiactl, nvidia-modeset, nvidia-uvm, /dev/dri, sound, video, hidraw/USB, input, /dev/sr0); DEVICES selects which classes stay, and any other resolved node makes firejail fall back to the host /dev with a warning. Landlock cannot restrict /dev at all (warned at launch).

Glob patterns are expanded against the host /dev at sandbox spawn time; entries that match nothing are silently dropped (so the NVIDIA defaults are a safe no-op on CPU-only nodes). After expansion, DEVICES_BLACKLIST is enforced — any resolved path matching a blacklist glob is dropped with a stderr notice.

The defaults expose the recurring HPC use case (NVIDIA driver nodes). Extend for AMD/Intel/sound/DRI/etc. as your workload requires:

DEVICES+=(/dev/snd /dev/dri/* /dev/kvm)

To replace defaults entirely (uncommon):

DEVICES=(/dev/something-specific)

See Device Passthrough for the full design rationale and per-backend behaviour.

DEVICES_BLACKLIST

Type array · Admin-enforced yes · Default

DEVICES_BLACKLIST=(
    /dev/mem        # direct kernel-memory access
    /dev/kmem
    /dev/port
    /dev/pts        # TIOCSTI keystroke injection on kernel < 6.2; also
                    # shadows bwrap's auto-mounted user-ns devpts on >= 5.4
    /dev/sd*        # raw block devices — filesystem bypass
    /dev/nvme*
    /dev/loop*
)

Devices that must not be bind-mounted, regardless of DEVICES. Admin baselines lock this in: users add but cannot remove admin-set entries. Without an admin install these defaults are the safety baseline.

To extend:

DEVICES_BLACKLIST+=(/dev/fuse)

BIND_DEV_PTS (deprecated)

Type scalar · Admin-enforced no (deprecated, kernel-aware shim) · Default false

Historical knob: when true, bound the entire host /dev into the sandbox. Replaced by DEVICES. For backward compatibility BIND_DEV_PTS=true is shimmed at config-load time:

  • On kernel < 5.4 it appends /dev/pts to DEVICES (the historical pty workaround — tmux needs the host devpts because bwrap's user-ns devpts on those kernels reports ptmxmode=000).
  • On kernel ≥ 5.4 it is a logged no-op. bwrap auto-mounts a working user-ns devpts on those kernels, and binding the host /dev/pts on top would shadow it with ptmxmode=000 and silently break pty allocation (tmux exits "create session failed"; script(1) reports "Permission denied").

Migration: drop the line. On kernel ≥ 5.4 you do not need it; on kernel < 5.4 bwrap's auto-devpts is fine for pty in most cases. Only add DEVICES+=(/dev/pts) if you're on a pre-5.4 kernel and tmux fails inside the sandbox without it; expect the TIOCSTI security caveat (kernel < 6.2) in return.


Environment

BLOCKED_ENV_VARS

Type array · Admin-enforced yes · Default service-credential names not caught by BLOCKED_ENV_PATTERNS (e.g. GITHUB_PAT, AWS_ACCESS_KEY_ID, DATABASE_URL, PGPASSWORD, KRB5CCNAME, TMUX, OLDPWD, …)

Explicit env-var names to strip from the sandbox environment. Names like GITHUB_TOKEN, OPENAI_API_KEY, AWS_SESSION_TOKEN, SSH_* etc. are already matched by BLOCKED_ENV_PATTERNS globs and don't need duplicating here. Use this for credentials with non-standard names (no _TOKEN / _SECRET / _KEY suffix).

To audit your environment for entries that might slip through both sets:

env | grep -iE 'token|key|secret|pat|auth'

BLOCKED_ENV_PATTERNS

Type array · Admin-enforced yes · Default

BLOCKED_ENV_PATTERNS=(
    "SSH_*"
    "*_TOKEN"  "*_SECRET"  "*_PASSWORD"  "*_CREDENTIAL"
    "*_API_KEY"  "*_SECRET_KEY"  "*_PRIVATE_KEY"
    "AZURE_*"  "GCP_*"  "GCLOUD_*"  "GOOGLE_CLOUD_*"
    "DOCKER_*"  "REGISTRY_*"
    "CI_*"  "GITLAB_*"  "JENKINS_*"  "BUILDKITE_*"  "CIRCLECI_*"
)

Glob patterns that block any matching env var. Patterns catch the common credential conventions automatically. Use ALLOWED_ENV_VARS to exempt a specific variable matched by these patterns.

Case sensitivity. Patterns are case-sensitive by default. Append the trailing /i flag — the industry-standard regex convention from sed (s/.../.../i), Perl (qr/.../i), and JavaScript (/.../i) — to opt one entry into case-insensitive matching. Env-var names are restricted to [A-Za-z0-9_] (POSIX IEEE Std 1003.1 §8.1) and cannot legitimately contain /, so the suffix is unambiguous. Default remains case-sensitive so admin baselines upgrading across versions keep their existing semantics.

BLOCKED_ENV_PATTERNS+=(
    "APP_SECRET_*"      # only blocks APP_SECRET_X (uppercase exact)
    "app_secret_*/i"    # blocks APP_SECRET_X, app_secret_x, App_Secret_X, …
)

The startup banner (unless SANDBOX_QUIET=true) reports how many env vars were blocked by pattern, so missing vars are diagnosable without leaking the names of credentials.

ALLOWED_ENV_VARS

Type array · Admin-enforced additive · Default ("ANTHROPIC_API_KEY" "OPENAI_API_KEY" "CODEX_API_KEY" "GOOGLE_API_KEY")

Variables listed here are never blocked, even if they appear in BLOCKED_ENV_VARS or match a BLOCKED_ENV_PATTERNS glob. The agent-API-key defaults are enabled so agents that use env-var auth (Codex, Aider, OpenCode, Gemini) work on first launch. Comment out any line to block that variable instead.

ALLOWED_ENV_VARS+=(
    "GITHUB_TOKEN"     # for `gh` CLI inside the sandbox
    "MY_APP_API_KEY"   # site-specific
)

HIDE_FROM_SANDBOX

Type array · Admin-enforced yes · Default ("SLURM_SCOPE" "CHAPERON_LOG_LEVEL" "CHAPERON_LOG_RETAIN_DAYS" "SANDBOX_QUIET" "HOME_ACCESS" "SANDBOX_NPROC_LIMIT" "SANDBOX_CONF")

Sandbox-setting env vars scrubbed from the sandboxed environment. These names configure the sandbox and chaperon on the host side and have no consumer inside the sandbox — forwarding them only tells an agent about its operator's configuration (logging policy, Slurm scoping, quiet mode). The scrub applies after the intentional orientation markers are set and is not overridden by ALLOWED_ENV_VARS: the list is admin-enforced, so users and projects can add names but never remove admin or default entries.

The built-in defaults are a floor even without an admin baseline: HIDE_FROM_SANDBOX=() (or any reassignment) in sandbox.conf or conf.d still hides them, and only adds whatever the new list contains.

The markers SANDBOX_ACTIVE, SANDBOX_BACKEND, SANDBOX_PROJECT_DIR, and _CHAPERON_FIFO_DIR are deliberately not hidden by default. Add SANDBOX_BACKEND to conceal which confinement mechanism is in use:

HIDE_FROM_SANDBOX+=("SANDBOX_BACKEND")

Caveats. Hiding SANDBOX_PROJECT_DIR or _CHAPERON_FIFO_DIR breaks in-sandbox sbatch/srun (the stubs need both to reach the chaperon). Hiding SANDBOX_ACTIVE removes the agent's "am I sandboxed" signal. PATH is refused with a warning — scrubbing it would break every exec inside the sandbox. Slurm re-entry is unaffected by the defaults: the compute-node sandbox re-reads config rather than depending on forwarded copies.

SANDBOX_ENV

Type array of KEY=VALUE strings · Admin-enforced additive · Default ()

Per-project environment variables for the sandboxed command. Set in conf.d/*.conf files guarded by _PROJECT_DIR so they only fire for the matching project.

  • Entries are applied to the sandboxed command only (as env NAME=VALUE… CMD inside the sandbox). They never reach the launcher, the chaperon or the backend binary, so they cannot change how the sandbox itself is built.
  • PATH is the exception: it is set on the host before the backend runs, so the backend's PATH prepends (chaperon stubs, sandbox bin) layer on top naturally. (Host commands the launcher runs, such as Slurm commands brokered by the chaperon, see this PATH too.)
  • Rejected with a WARNING: SANDBOX_ENV entry 'NAME' ignored: … line: names that are not valid identifiers; sandbox config variables (NETWORK_FILTER_MODE, PRIVATE_TMP, HOME_ACCESS, … — set those as config values, where admin enforcement applies); launcher-internal names (leading _) and the SANDBOX_*, CHAPERON_*, LANDLOCK_*, NETWORK_* families; names in HIDE_FROM_SANDBOX; names blocked by BLOCKED_ENV_VARS / BLOCKED_ENV_PATTERNS (add them to ALLOWED_ENV_VARS to pass them); and names an enabled agent profile sets (e.g. CLAUDE_CONFIG_DIR).
# conf.d/genomics.conf
[[ "$_PROJECT_DIR" == /fh/fast/mylab/genomics/* ]] || return 0
SANDBOX_ENV+=(
    "PATH=/fh/fast/mylab/genomics/bin:${PATH}"
    "MY_PIPELINE_REF=/fh/fast/shared/reference_genomes/hg38"
)

Backend & isolation

SANDBOX_BACKEND

Type scalar · Admin-enforced no · Default auto (priority bwrap → firejail → landlock)

Which isolation backend to use. Overridable by --backend bwrap on the command line or by exporting SANDBOX_BACKEND in the environment — both override config-file values, since explicit selection should win over config defaults.

Value Backend Notes
auto (or empty) best available bwrap → firejail → landlock
bwrap Bubblewrap primary, recommended dependency
firejail Firejail fallback (setuid root)
landlock Landlock LSM fallback (kernel ≥ 5.13, no mount/PID namespaces; documented gaps)
SANDBOX_BACKEND="bwrap"          # force bwrap; fail if unavailable

SANDBOX_MODULES

Type array · Admin-enforced additive · Default ()

Lmod modules to load before backend detection. Use this on HPC systems where sandbox dependencies (e.g. a newer bubblewrap) are only available via module load. The sandbox sources lmod init from common locations (/etc/profile.d/lmod.sh, /usr/share/lmod/lmod/init/sh, /app/lmod/lmod/init/sh) if module isn't already on PATH.

SANDBOX_MODULES=("bubblewrap/0.11.1-GCCcore-12.3.0")

PRIVATE_TMP

Type scalar · Admin-enforced harden-only (admin true is sticky) · Default true

Isolate /tmp with a private tmpfs. Each sandbox gets its own /tmp. Set to false if the sandboxed process needs shared /tmp access — MPI shared-memory transport (OpenMPI, MVAPICH) and NCCL inter-GPU sockets put files there.

Backend support: bwrap (--tmpfs /tmp) and firejail (--private-tmp). Landlock has no mount namespace — the value is honoured at config level but /tmp is not actually isolated; the docs site's Known Limitations table lists this as a Landlock gap.

PRIVATE_IPC

Type scalar · Admin-enforced harden-only · Default true

Isolate the SysV IPC namespace and /dev/shm. Each sandbox gets its own IPC namespace, preventing the agent from reading or corrupting shared memory of processes outside the sandbox. MPI/NCCL within a single Slurm job are unaffected — all ranks share one sandbox.

Backend support: bwrap (--unshare-ipc + private /dev/shm tmpfs) and firejail (--ipc-namespace + an empty private /dev/shm in its private /dev; /dev/shm is blocked instead when DEVICES forces the host /dev). Landlock cannot isolate IPC.

FILTER_PASSWD

Type scalar · Admin-enforced harden-only · Default true

Generate a minimal /etc/passwd (system UIDs < 1000 + the current user) and override /etc/nsswitch.conf to use files only (no ldap/sss). Prevents LDAP/AD user enumeration via getent passwd, finger, etc. — getent passwd returns ~35 entries instead of every user on the directory.

Backend support:

  • bwrap — overlays /etc/passwd + /etc/nsswitch.conf via --ro-bind.
  • firejail — blocks NSS daemon sockets (nscd, nslcd, sssd).
  • Landlock — not supported (no mount namespace; user enumeration succeeds).

Munge, Slurm, and normal user/group resolution are unaffected. Set false if the sandboxed process needs LDAP user lookups (rare).

On bwrap, id shows supplementary groups as nogroup (65534) regardless of this setting. Cosmetic only — the kernel uses host credentials for filesystem access, so file permissions still work correctly.

SANDBOX_NPROC_LIMIT

Type scalar (integer or empty) · Admin-enforced no · Default "" (no limit)

Defense-in-depth against fork bombs. Caps the total processes the sandbox user can run via RLIMIT_NPROC (ulimit -u for bwrap/Landlock, firejail --rlimit-nproc for firejail). Note that RLIMIT_NPROC counts per-UID system-wide, not per-sandbox — a fork bomb inside the sandbox can fill the per-UID limit and kill the user's shells/editors outside the sandbox. Admin cgroups with pids.max are the primary defense; this is supplemental.

SANDBOX_NPROC_LIMIT="4096"

MOUNT_GUARD

Type scalar (repair | kill | warn | off) · Admin-enforced harden-only when the admin file sets it (off < warn < repair < kill) · Default repair Also MOUNT_GUARD_INTERVAL (whole seconds, default 5) and MOUNT_GUARD_REBIND (true | false, default true).

A bind mount can disappear from a sandbox while it runs. Linux detaches every mount on a path that is renamed over or deleted from another mount namespace, and an NFS client that finds a cached directory stale detaches every mount at or below it, in every namespace. Examples: an editor or claude outside the sandbox saving ~/.claude/settings.json through a temp file and rename; a package upgrade replacing a masked /usr/bin/sbatch; a file server hiccup on the project filesystem. The path then shows whatever is mounted underneath. For the project dir that is usually a read-only parent (writes fail), but a lost read-only overlay or BLOCKED_FILES mask fails open.

On bwrap and firejail a watcher runs outside the sandbox for the whole session. Every MOUNT_GUARD_INTERVAL seconds it compares the sandbox's mount table with the mounts the backend set up and reports each loss once, with a bell, on the launching terminal (and to syslog, and as a tmux message when $TMUX is set):

  • exposed: a mask, or a read-only overlay that now sits in a writable host mount, is gone on a policy path (under $HOME, the project dir, an EXTRA_WRITABLE_PATHS entry or the install dir, or a BLOCKED_FILES/EXTRA_BLOCKED_PATHS entry), or a read-only mount turned read-write. With kill (opt-in) the sandbox is terminated.
  • degraded: any other loss (a writable bind, a loss that falls through to something read-only or to a tmpfs, a system mask such as /usr/bin/sbatch). Reported only.

repair (the default) puts a lost mask or read-only overlay back into the running sandbox and reports it (… and RESTORED). It re-creates the same kind of mount the backend set up, at the same path: /dev/null over a masked file, an empty tmpfs over a masked directory, a read-only bind of whatever is now at a path that was bound read-only onto itself (your ~/.claude/settings.json after claude outside rewrote it, .sandbox-state after it was replaced). A repair never shows the sandbox anything it cannot already see and never grants write access. It is refused, and reported as by warn, when the path is now a symlink (for example one the agent planted while the path was exposed), is missing, changed type or owner, or when other expected mounts lived below it (a tmpfs $HOME); a refused repair is retried with exponential backoff (the interval, doubling, at most 60 s), so a path that reappears is protected again. Between the loss and the repair the path was exposed for up to MOUNT_GUARD_INTERVAL seconds, and whatever the agent read or wrote in that window stands; the report says so. Repair needs bwrap and python3 (a read-only overlay needs Linux 5.12 or newer for the descriptor-based mount API; writable and nested binds use plain mount(2) and work on older kernels); firejail's namespaces belong to root and cannot be entered, so there (and wherever repair is impossible) repair behaves as warn.

Lost project bind. The case seen most often on network filesystems is the writable bind of the project directory itself disappearing together with the read-only binds nested below it (for example .sandbox-state). Every path below the project then falls through to the read-only parent bind and all writes fail with EROFS, while the host is unaffected. With MOUNT_GUARD_REBIND=true (the default) the watcher re-binds such a path onto itself, parents before children (by path depth), instead of leaving the sandbox degraded until it is restarted. Each bind comes back as the backend set it up: the same path, read-write or read-only, and with the nosuid, nodev, noexec and noatime options it had when the watcher armed. A bind is only attempted when the path still resolves, without symlinks, to the host's own object (same device and inode); it is held back while an expected mount above it is still missing, and refused while one below it is still present (it would be hidden). A failed attempt is retried with exponential backoff (the interval, doubling, at most 60 s), never in a tight loop, and a new mount that does not show the right object or read-only state is detached again rather than left in place. So is an attempt that reported success but left no mount at the path (the kernel can detach a fresh bind again while it is still invalidating the directory): it is reported once and retried until it shows, and the helper never mounts over a mount that is already right, so nothing is stacked. Only a new mount in the wrong read-only state is final and not retried. The helper refuses unless the sandbox's user namespace is owned by your uid and is not the host's, and nothing outside the backend's own bind list is ever mounted. Processes whose working directory was on the lost mount keep seeing the old (detached) view until they cd again. MOUNT_GUARD_REBIND=false restores the earlier behaviour (only leaf masks and read-only overlays are repaired; writable binds are reported). A shorter MOUNT_GUARD_INTERVAL shortens the window with a read-only project.

Live reload. A mount change in the config can be applied to a running bwrap session with agent-sandbox reload; the guard then watches the new set. See Live reload.

warn reports both kinds and never terminates or repairs; kill terminates the sandbox on an exposure (and repairs nothing); off disables the watcher. Landlock has no mounts to watch. On firejail the report reaches the terminal only when stderr is a terminal (the setuid firejail process hides its descriptors); syslog always gets it.

MOUNT_GUARD="kill"          # terminate instead of repairing when a protected path is exposed
MOUNT_GUARD_INTERVAL=2      # shorter exposure window before a repair (default 5)
MOUNT_GUARD_REBIND=false    # do not re-bind a lost writable project bind

SANDBOX_QUIET

Type scalar · Admin-enforced no · Default false

Suppress the sandbox's informational startup output — the one-line banner (backend, project dir, home-access mode), the count of env vars blocked by pattern, and the "created/updated sandbox.conf" notices. Useful inside scripts and CI where the chatter is noise.

Quiet silences noise, never safety output: errors, FATAL:/WARNING: config-integrity messages, per-agent blocked-credential warnings, and anything on a failure path still print under SANDBOX_QUIET=true.

SANDBOX_QUIET=true               # in sandbox.conf or conf.d/*.conf
SANDBOX_QUIET=true sandbox-exec.sh -- claude   # or as an env override

Precedence: env var > conf.d/sandbox.conf > default (false). An explicit SANDBOX_QUIET in the launch environment wins over any config-file value, matching the other launch-overridable scalars (HOME_ACCESS, SLURM_SCOPE).

Chaperon retains quiet across Slurm submission. When a session is quiet, the chaperon (which brokers sbatch/srun from inside the sandbox, running outside it) bakes the quiet decision into every job it submits, so the compute-node re-entry stays quiet too. This holds even if the in-sandbox process unsets SANDBOX_QUIET or submits with --export=NONE. Beyond consistency this is a confidentiality boundary: it stops a process inside the sandbox from recovering the suppressed banner — backend, project dir, home mode, blocked-var counts — by submitting a job and reading its log.


Network filter

Restricts the agent's outbound network access. Closes the canonical identity-hijack threat (local-MTA abuse via SMTP submission) and the adjacent lateral-movement / exfiltration surface (webhook-as-mail APIs, paste sites, DoH endpoints, legacy r-services). See the Network filter reference for the threat model, helper sourcing (pasta probe), per-backend support, precedence rules, worked examples, and a verification recipe.

NETWORK_FILTER_MODE

Type scalar · Admin-enforced harden-only (user can only request equal or stricter) · Default filtered

How the agent's outbound network is isolated.

Value Mechanism Network reach
open share the host network namespace; no isolation full host network (legacy behaviour)
filtered (default) new Linux network namespace + pasta helper; default-deny port floor plus user/admin NETWORK_BLOCKLIST general outbound TCP/UDP/DNS minus the threat-class ports
proxied (new in 0.10.1) new netns with no native outbound; agent traffic is mediated by a host-side HTTP CONNECT + SOCKS5 daemon (tools/proxy/agent-sandbox-proxy.py) reached via bind-mounted Unix sockets + an in-sandbox TCP↔Unix bridge. HTTP_PROXY/HTTPS_PROXY/ALL_PROXY pre-set only what the proxy admits — proxy-aware tools (curl, pip, git, gh, conda, Claude SDK) work; raw TCP/UDP/ICMP, ssh direct, dig/nslookup, ping, bash /dev/tcp/* all break
isolated new netns with no network at all none (DNS / pip / git break inside the sandbox)

filtered requires pasta on the host. agent-sandbox ships a static pasta binary at tools/pasta/<arch>/pasta (x86_64 in v1.1); install passt from your distro (apt install passt, dnf install passt, brew install passt) for a newer system pasta on PATH. When filtered cannot be delivered (no pasta, kernel < 5.7 without setcap cap_net_raw+ep, landlock backend), the resolver falls back per NETWORK_FILTER_FALLBACK.

proxied requires python3 on PATH and the in-tree helper tools/proxy/agent-sandbox-proxy.py (always present in a make install-ed deployment; no per-arch build). Bwrap only in v0.10.1; firejail/landlock unsupported. See Proxied mode for the enforcement surface and breakage list.

Strictness ordering: open < filtered < proxied < isolated. If the admin baseline pins a value, the user's effective value cannot be weaker; an attempted weakening is restored at config-load with a WARNING.

NETWORK_FILTER_MODE="filtered"          # default — port-level enforcement

See the Modes section of the reference page for what pasta -T/-U actually enforces in v1.1 (port-level entries) versus what is tracked for v1.2 L7-proxy work (hostname-level entries).

NETWORK_FILTER_FALLBACK

Type scalar · Admin-enforced harden-only · Default open

What happens when the requested NETWORK_FILTER_MODE is not deliverable on the resolved backend.

Value Behaviour
strict Refuse to launch. Loud error enumerating the fix paths.
stricter Fall back ONLY to a STRICTER mode. Loud warning. Walks the strictness chain LEAST-strict-step-up first (smallest weakening), so a degraded-pasta host pinning MODE=filtered lands on proxied (v0.10.1+) before isolated. If no stricter mode is possible (landlock has no netns), refuse to launch.
open (default) Fall back ONLY to a LESS restrictive mode, preferring the most-strict less-strict option first. NEVER strengthens — default-config users (MODE=filtered FALLBACK=open) on a degraded host fall to open, never silently to proxied. Loud warning.

Strictness ordering: open < stricter < strict. Admin-pinned values cannot be weakened by the user.

The open default keeps the sandbox usable on legacy-kernel (< 5.7) deployments where pasta's SO_BINDTODEVICE forwarding probe trips and filtered becomes unavailable — without it, every such host would refuse to launch. Sites where the stronger posture is mandatory should pin stricter (or strict) in the admin baseline.

See the fallback decision matrix for the per-combination outcome under each policy, and the per-backend support matrix for which modes each backend can deliver.

NETWORK_BLOCKLIST

Type array · Admin-enforced yes (admin entries become a floor the user cannot remove) · Default built-in floor in sandbox-lib.sh — SMTP submission ports (24/25/465/587/2525) on loopback and 0.0.0.0/0, transactional-email HTTPS APIs (api.mailgun.net, api.sendgrid.com, …), webhook-as-mail surfaces (hooks.slack.com, *.webhook.office.com, …), anonymous file-drop hosts (transfer.sh, 0x0.st, …), public paste services, DoH endpoints (cloudflare-dns.com, dns.google, …), DoT port 853, and legacy r-services ports 23/79/113/512/513/514. See sandbox.conf for the commented, site-tunable entries (LDAP/Kerberos, Slurm, SMB/RDP/VNC, campus mail-relay CIDR).

Destinations to deny when NETWORK_FILTER_MODE is filtered. Entries are bash-glob-aware patterns over hostnames, IPs, CIDRs, and ports. The default floor is always applied; user += entries are additive; admin NETWORK_BLOCKLIST entries become a floor that user config cannot remove.

NETWORK_BLOCKLIST+=(
    "*.untrusted-vendor.com"   # block a DNS suffix
    "10.0.0.0/8:25"            # site-specific CIDR + port
    "203.0.113.5"              # single host, all ports
)

Pattern syntax (host / host:port / CIDR / CIDR:port / bare port / *.suffix / *), the runtime apply order, the most-specific-rule-wins precedence model, and worked examples live in the reference page: Pattern syntax and Precedence model.

v1.1 enforcement scope. pasta's -T/-U port-exclusion enforces port-level entries (bare port 25, loopback 127.0.0.1:25, universal 0.0.0.0/0:25). Hostname and wildcard-hostname entries (e.g. hooks.slack.com, *.s3.amazonaws.com) are tracked for v1.2 L7-proxy work and are silently skipped at the pasta boundary today (set NETWORK_FILTER_VERBOSE=1 to see the skip notes). The default port-class closure already shuts the motivating identity-hijack threat. See Modes — enforcement scope for the full list.

NETWORK_BLOCKLIST_EXCEPT

Type array · Admin-enforced additive; user entries covered by an admin-set NETWORK_BLOCKLIST pattern are stripped at config-load with a WARNING · Default ()

Exceptions that carve holes in NETWORK_BLOCKLIST under the most-specific-rule-wins precedence model. Use to allow specific destinations that would otherwise be caught by a broader blocklist pattern, or to express deny-by-default via the implicit-allowlist idiom.

# Block all of S3 except one bucket
NETWORK_BLOCKLIST+=("*.s3.amazonaws.com")
NETWORK_BLOCKLIST_EXCEPT+=("mybucket.s3.amazonaws.com")

# Implicit-allowlist idiom (deny-by-default)
NETWORK_BLOCKLIST+=("*")
NETWORK_BLOCKLIST_EXCEPT+=(
    "github.com" "api.github.com" "codeload.github.com"
    "api.anthropic.com" "api.openai.com"
    "pypi.org" "files.pythonhosted.org"
)

Admin policy is absolute: a user exception covered by an admin-set NETWORK_BLOCKLIST pattern is stripped at config-load. See Admin precedence and Implicit-allowlist idiom on the reference page.

NETWORK_MAIL_BLOCK

Type scalar auto | on | off · Admin-enforced harden-only (user can only request equal or stricter; off < auto < on) · Default auto

Defense-in-depth above NETWORK_FILTER_MODE's port-level SMTP block. When active, the launcher replaces every canonical mailer binary inside the sandbox — sendmail, mail, mailx, mutt, msmtp, ssmtp, s-nail, swaks, the postfix admin tools, exim, dma, qmail clients — with a stub (tools/mail-block/mail-block-stub.sh) that prints a deterrent message to stderr and exits 77 (sysexits EX_NOPERM). The stub catches the execve syscall, so the agent learns the policy in human-readable terms before the network filter drops a connection.

Value Behaviour
auto (default) on whenever the configured NETWORK_FILTER_MODE OR the resolved one is anything other than open. Tracks the user's intent, not only the post-fallback realised state: if NETWORK_FILTER_FALLBACK=open degrades a filtered request to open because pasta is missing, the configured intent ("constrain egress") still keeps the stub layer on — defense-in-depth earns its name precisely when the primary layer collapsed. Only when BOTH configured and resolved are open does the layer step aside.
on always on, regardless of NETWORK_FILTER_MODE.
off never on. Escape hatch for sites that legitimately need the canonical mailer binaries visible. Rare — the v0.10.0 port filter already breaks them at the socket layer.

The two layers compose. The stub catches every UNIX tool that respects the sendmail interface or has a canonical binary name on PATH; the network filter catches the application-level remainder (python -c 'import smtplib...', curl smtp://, nc <host> 25). Evaluated CONFIG > NETWORK so the agent sees the policy text before the kernel drops the connection.

Mechanism: bind-mount the stub --ro-bind over every canonical absolute path that exists on the host (/usr/{bin,sbin}/<name>, /usr/lib/sendmail, /var/qmail/bin/qmail-*), plus a per-launch symlink farm of the same names under $TMPDIR that is --ro-bind'd at the same path on both sides of the sandbox boundary (mirroring the chaperon FIFO and proxy socket-dir pattern) and prepended to PATH so name-resolution finds the stub before any unstubbed copies (e.g. /usr/local/bin/<name>, Lmod-injected /app/software/.../bin/<name>).

Argv echo is sanitized: the stub strips bytes outside [:graph:] from basename "$0" and reports the argv count, never the args themselves — a hostile argv[0] containing ANSI / OSC-8 / CR / NUL bytes cannot rewrite the agent's terminal or smuggle clickable URLs into log scrapers.

# Default — auto picks the right thing for the active network mode
NETWORK_MAIL_BLOCK="auto"

# Always on, even when MODE=open
NETWORK_MAIL_BLOCK="on"

# Off (rare — document the use case)
NETWORK_MAIL_BLOCK="off"

Backend support: bwrap only in v0.10.1 (firejail and landlock fall back to no stub; the network filter still applies on the supported backends per their own matrix). See Outbound mail policy for the threat-model / mechanism / failure-mode write-up.

NETWORK_FILTER_SKIP_HELPER_PROBE

Type env override (scalar 0 / 1) · Admin-enforced no · Default 0

Skip the ~50 ms pasta-forwarding probe that runs at session-start to detect kernels where SO_BINDTODEVICE is gated behind CAP_NET_RAW (most kernels < 5.7 without the 2020 relaxation backported). Set only after verifying pasta's host-side forwarding actually works in your environment — typically after an admin has run setcap cap_net_raw+ep on a system-wide pasta binary, or on kernel ≥ 5.7.

NETWORK_FILTER_SKIP_HELPER_PROBE=1 agent-sandbox bash

Do not set this as a workaround for the pasta degraded to loopback-only fallback warning. The probe exists precisely to catch that silent-loopback-only failure mode; skipping it re-introduces the gap where filtered mode would launch with the agent silently unable to reach anything except 127.0.0.1. See Probe escape hatch for the full reasoning.


Slurm

SLURM_SCOPE

Type scalar · Admin-enforced no · Default project

Which jobs the chaperon-proxied squeue, scancel, scontrol, and sstat can see and operate on. The proxy filters output and validates targets against the configured scope.

Value What's visible / cancellable
session Only jobs submitted by this sandbox session (one shell).
project (default) Jobs from any sandbox session with the same project dir — survives reconnects, multi-window workflows.
user All of the calling user's jobs, including non-sandbox ones.
none No restriction (full access to your own jobs — squeue --me semantics).

sacct is always scoped to the current user (--user=$(whoami) injected by the chaperon); --allusers and cross-user --user=... are denied with an actionable hint. sacctmgr user/account enumeration is denied entirely.

Override per-session via env: SLURM_SCOPE=session agent-sandbox claude.

CHAPERON_LOG_LEVEL

Type scalar · Admin-enforced no · Default info

Verbosity of the chaperon's per-session log file (one file per sandbox session at ~/.local/state/agent-sandbox/chaperon/<hostname>_<PID>_<timestamp>.log).

Level What lands in the log
debug All requests, full handler exit codes, protocol details (script content captured).
info (default) Startup, shutdown, each request, non-zero handler exits.
warn Timeouts, validation failures, non-zero handler exits.
error Only security rejections and hard failures.

CHAPERON_LOG_RETAIN_DAYS

Type scalar (integer) · Admin-enforced no · Default 7

How many days of chaperon logs to keep. Older logs are pruned at each chaperon startup. A total size cap of 50 MiB is also enforced (oldest-first deletion when exceeded). Filenames include the hostname for NFS-safe uniqueness across machines.


Agent profiles

ENABLED_AGENTS

Type array · Admin-enforced additive · Default ("claude" "codex" "gemini")

Names of agent profiles in agents/<name>/ to enable. Each enabled agent contributes:

  • Writable paths (e.g. ~/.claude, ~/.codex) folded into HOME_WRITABLE.
  • Read-only paths folded into HOME_READONLY.
  • Per-agent instruction files folded into BLOCKED_FILES (the overlay then exports a *_CONFIG_DIR env var pointing at the sandbox-merged copy).

Disabled agents contribute nothing — their config dirs stay invisible inside the sandbox. Enable only the agents you actually use, so ~/.pi or ~/.config/opencode (which could be unrelated user data) doesn't become writable for users who don't run those agents.

Built-in profiles: claude, codex, gemini (default-enabled), aider, opencode, pi (opt-in).

ENABLED_AGENTS+=("aider")              # add to defaults
ENABLED_AGENTS=("claude")              # solo-claude profile

Adding support for a new tool: drop in agents/<name>/{config.conf,overlay.sh,agent.md}, then add "<name>" to this list. See agents/claude/config.conf for the schema.

SUPPRESS_AGENT_WARNINGS

Type array · Admin-enforced additive · Default ()

Silence per-agent credential/path warnings emitted at startup. The sandbox checks each enabled agent profile and warns if the declared credentials/paths look unreachable (missing env vars and no writable auth directory). Useful when you intentionally isolate an agent (e.g. dropped ~/.claude from HOME_WRITABLE to force a fresh login each session).

SUPPRESS_AGENT_WARNINGS=("claude")     # silence Claude only
SUPPRESS_AGENT_WARNINGS=("all")        # silence every agent

Per-project overrides (conf.d/)

Different projects often need different data access. Drop files in ~/.config/agent-sandbox/conf.d/*.conf to add mounts only when the project directory matches. Each file is sourced in lexical order after sandbox.conf, so use += to append to the global arrays.

# conf.d/genomics.conf
[[ "$_PROJECT_DIR" == /fh/fast/mylab/genomics/* ]] || return 0

READONLY_MOUNTS+=(
    "/fh/fast/shared/reference_genomes"
)
EXTRA_WRITABLE_PATHS+=(
    "/fh/scratch/delete30/mylab/pipeline-output"
)
SANDBOX_ENV+=(
    "MY_PIPELINE_REF=/fh/fast/shared/reference_genomes/hg38"
)

The _PROJECT_DIR variable is set by the sandbox before sourcing conf.d files. Returning early when the project doesn't match keeps the file a no-op for unrelated projects.

conf.d files are untrusted layers like sandbox.conf: admin enforcement is re-applied after them, and explicit launch overrides (--backend, the env vars listed under How config is loaded) still win over anything they set.

See conf.d/example.conf in the install for a template.


Common customizations

# Add a read-only data directory
READONLY_MOUNTS+=("/shared/other_lab/data")

# Add a writable output directory beyond the project dir
EXTRA_WRITABLE_PATHS=("/shared/scratch/agent-output")

# Block a sensitive subdirectory inside an otherwise-visible mount
EXTRA_BLOCKED_PATHS=("/shared/lab/clinical_restricted")

# Allow GitHub CLI inside the sandbox
HOME_READONLY+=(".config/gh")
ALLOWED_ENV_VARS+=("GITHUB_TOKEN" "GH_TOKEN")

# Open an extra device (audio + DRI render nodes)
DEVICES+=(/dev/snd /dev/dri/*)

SSH keys. ~/.ssh is excluded from HOME_READONLY by default — the agent cannot see it. Do not add it. On HPC clusters with passwordless SSH between nodes, an agent with access to ~/.ssh can SSH to localhost for an unsandboxed shell. If the agent needs git access, prefer deploy keys scoped to a single repo, or HTTPS with a fine-grained token (add the token var to ALLOWED_ENV_VARS).

Claude Code permissions (settings.json)

For Claude Code, the sandbox overlays ~/.claude/settings.json to auto-allow tools (Bash, Read, Edit, Write, Glob, Grep, NotebookEdit) that are already restricted by the kernel-enforced filesystem isolation. Your existing rules (including deny) are preserved. Customise via ~/.config/agent-sandbox/agents/claude/settings.json.


Cross-references