Skip to content

Configuration reference ​

TOML. Unknown keys are rejected, so a typo is an error rather than a silently ignored rule.

Discovery order ​

Lowest precedence first:

  1. The implicit grant of the target executable, read and execute.
  2. Profile: the untrusted floor, plus the profile named by --profile, or one that claims this target with applies_to. A profile is either bundled or a file in $XDG_CONFIG_HOME/bailey/profiles/.
  3. $XDG_CONFIG_HOME/bailey/config.toml, or ~/.config/bailey/config.toml.
  4. Every bailey.toml found walking up from the target's directory and from the working directory, ordered by path depth, shallowest first. A file found by both walks contributes once; at equal depth the working directory wins. Each applies only once trusted with bailey trust <file>; see trusting a config.
  5. The file given to --config.

Layers 1 to 3 and layer 5 need no trust record: each is a file you wrote or a path you typed. Only the discovered files in layer 4 are gated.

[filesystem] ​

KeyTypeDescription
readlist of pathsGrant read on each path hierarchy
writelist of pathsGrant write on each path hierarchy
executelist of pathsGrant execute on each path hierarchy
denylist of pathsRetract a grant of the same path and record the path as denied
read_onlylist of pathsMake a path read-only inside a writable grant. Needs the isolation layer
resetboolClear all filesystem grants from lower layers before applying this one

Rights for the same path combine across layers. A grant on a directory covers everything beneath it.

toml
[filesystem]
read = ["~/.config/app", "./assets"]
write = ["./saves"]
execute = ["./program"]
deny = ["~/.config/app/token"]

Granting and denying the same path within one layer is an error.

Placing a grant somewhere else ​

An entry may be a table naming where the path should appear to the target:

toml
[filesystem]
read = [{ path = "/home/you/projects/thing", at = "/workspace" }]
write = [{ path = "/home/you/projects/thing", at = "/workspace" }]

The target sees /workspace, and the host path does not exist for it at all. This needs the isolation layer, since without a reconstructed root there is nowhere else for a path to be.

Paths are otherwise preserved exactly, and that is worth keeping: a program that resolves anything relative to its own location breaks when moved, which is why a granted symlink is recreated rather than bound through. Reach for at when the name of a path is itself the thing to withhold. A directory named after the person running the sandbox tells a target who they are and how the host is laid out, and no amount of access control takes that back once the target has read it.

A deny beneath a relocated grant is applied at the new location, and two different paths may not be placed at the same location.

A read-only island ​

read_only is how you keep part of a writable hierarchy from being written:

toml
[filesystem]
write = ["~/.local/share/thing"]
read_only = ["~/.local/share/thing/versions"]

Unlike deny, the contents stay readable; only writes are refused. It is enforced by mounting the path over itself read-only, which the VFS honours whatever Landlock says, so it needs the isolation layer. That is the default; under --no-isolate the run warns and the path stays writable.

A nested deny needs the isolation layer

A denial of a path inside a granted directory is enforced by covering the path with an empty read-only filesystem, which only the isolation layer can do. That layer is on by default; under --no-isolate the denial is reported as unenforced before the run starts.

[network] ​

KeyTypeDescription
egress"deny" or "allow"Outbound policy. Default "deny"
egress_allowlist of tablesPermitted destinations, { host, port }
bind_portslist of integersTCP ports the target may bind
toml
[network]
egress_allow = [{ host = "*", port = 443 }]
bind_ports = [27015]

egress_allow takes precedence over egress when both are present. bind_ports accumulate across layers; egress is replaced by the nearest layer that sets it.

  • host is advisory. Landlock matches on TCP port only, and bailey warns when host is anything other than *.
  • An entry without a port is a resolution error, because the resulting policy would mean the opposite of what it says.
  • Only TCP is restricted. See known limitations.

[[device]] ​

An array of tables, one per device.

KeyTypeDescription
pathpathDevice node or directory
accessstringAny combination of r, w, x
toml
[[device]]
path = "/dev/dri"
access = "rw"

Device grants use filesystem semantics and accumulate across layers like filesystem grants.

[[fuse]] ​

An array of tables, one per FUSE filesystem bailey mounts itself. Bailey runs the mount command as the caller before the target starts, with BAILEY_MOUNTPOINT naming the host directory to mount onto, and unmounts when the run ends. The target sees the filesystem at at and never holds the mount syscalls. See desktop services and mounts.

KeyTypeDescription
mountstringShell command that performs the mount
atpathWhere the mount appears to the target
toml
[[fuse]]
mount = "rclone mount secret:project"
at = "/mnt/project"

Entries accumulate across layers and are cleared by filesystem.reset = true. A failing mount command refuses the run. Isolation is required; --no-isolate with a [[fuse]] entry in the policy is an error.

self_fuse ​

A top-level boolean that lets the target mount its own filesystems. It keeps the mount syscalls, holds CAP_SYS_ADMIN inside the run's user namespace, and gets /dev/fuse without a [[device]] entry:

toml
self_fuse = true

Like egress, the nearest layer that sets it decides. Isolation is required; self_fuse without --isolate is refused at startup.

The cost is structural, and bailey refuses combinations that would promise more than it can deliver:

  • Recent kernels refuse a new filesystem to any process under a Landlock domain, and a FUSE mount is a new filesystem. A self_fuse run therefore has no Landlock ruleset; confinement rests on the reconstructed root alone. See known limitations.
  • A read_only path is a read-only bind the target could remount writable, so any read_only entry alongside self_fuse is a resolution error.
  • A deny beneath a grant is an empty mount the target could remove, so that combination is a resolution error too. A deny no grant reaches is absent from the root outright and stays enforced.
  • An egress allowance is a Landlock port restriction, and it is enforced by the same ruleset the switch gives up, so that combination is a resolution error too. Allow-everything and deny-everything are both unaffected: the first promises no restriction, and the second is held by the run's own network namespace rather than by Landlock.

[[socket]] ​

An array of tables, one per desktop service socket relayed into the sandbox. Bailey listens on a socket of its own and carries connections to the real one, so a single service is reachable while the directory holding it, and every other socket in it, stays ungranted.

Either name a service, or name both ends yourself, never both:

KeyTypeDescription
servicestringOne of wayland, x11, pulse, dbus, url-open
hostpathThe host socket to relay, when naming a custom one
atpathWhere the target sees it; defaults to host
toml
[[socket]]
service = "wayland"

[[socket]]
service = "url-open"

[[socket]]
host = "${XDG_RUNTIME_DIR}/pipewire-0"
at = "${XDG_RUNTIME_DIR}/pipewire-0"

Named services take their paths from the session the run is launched from, so XDG_RUNTIME_DIR must be set for them. The environment follows the relay: the target's XDG_RUNTIME_DIR is a per-run directory, and each service sets the variables that point a program at it. Entries accumulate across layers and are cleared by filesystem.reset = true. A relay overlapping a grant is a resolution error, because the relay exists to keep the directory ungranted.

[[forward]] ​

An array of tables, one per host TCP port carried onto the sandbox's own loopback. The target dials 127.0.0.1:port the way it would dial a local daemon, and bailey carries the connection out to the host. The network namespace keeps its edge: nothing beyond the named ports is reachable.

KeyTypeDescription
portintegerThe port inside the sandbox; required, not 0
host_portintegerThe host port it lands on; defaults to port
toml
[[forward]]
port = 9876

[[forward]]
port = 5432
host_port = 15432

Entries accumulate across layers and are cleared by filesystem.reset = true. Two different host ports for one sandbox port is refused, as is a forward sharing a port the policy already binds. Isolation is required: the listeners are bound inside the target's own loopback and handed to bailey over a file descriptor before exec, which is what keeps them off the host. Egress stays denied; the forward is the one exception, and it carries only what dials it.

[resources] ​

KeyTypeDescription
memorystringMaximum memory. Bytes, or a suffix
pids_maxintegerMaximum processes and threads
cpu_percentintegerCPU quota as a percentage of one core
file_maxstringLargest single file the target may create
tmp_sizestringSize of the private /tmp. Default 64MiB
shm_sizestringSize of the private /dev/shm. Default 256MiB

Accepted size suffixes, case-insensitive: b, k/kb (1000), kib (1024), m/mb, mib, g/gb, gib, t/tb, tib.

toml
[resources]
memory = "2GiB"
pids_max = 512
cpu_percent = 150
file_max = "1GiB"

Each key is replaced by the nearest layer that sets it. memory, pids_max and cpu_percent are applied through cgroup v2, best-effort: without a writable delegated cgroup they are skipped, and the run reports that.

file_max is different. It is an RLIMIT_FSIZE, so it holds on any host, cgroups or not, and it is inherited by every process the target starts. A write past it raises SIGXFSZ, and fails with EFBIG for a target that handles the signal.

It bounds one file rather than total usage. Linux has no rootless way to cap what a process tree writes in aggregate, because cgroups have no disk controller and a sized filesystem needs a mount. What this stops is the runaway log or dump that fills a disk, not a target that writes many small files.

[env] ​

KeyTypeDescription
passlist of namesForward these caller variables. A trailing * matches by prefix
settableDefine variables outright. ${VAR} and a leading ~ expand, as they do in paths
denylist of namesRemove these from the final environment, including the base set
resetboolDiscard everything lower layers passed or set
toml
[env]
pass = ["MANGOHUD*"]
set = { RUST_LOG = "debug" }
deny = ["TERM"]

The target's environment is built, not inherited: it starts empty, gets a base set of PATH, HOME, TMPDIR, TERM, LANG, LC_*, USER, LOGNAME, SHELL, TZ, BAILEY_SANDBOX, and BAILEY_SANDBOX_NET, and takes nothing else from the caller unless named here. See environment and storage.

applies_to ​

A top-level key, meaningful only in a profile under $XDG_CONFIG_HOME/bailey/profiles/. It lists the programs the profile is for, so that bailey selects it without --profile.

toml
applies_to = ["claude", "/opt/thing/bin/thing"]

An entry with no / matches the target's file name; one with a / must match the resolved path. An explicit --profile overrides it, and two profiles claiming one target is an error.

home ​

A top-level key giving the host directory to use as the target's private home, overriding the derived $XDG_DATA_HOME/bailey/<target>/home.

toml
home = "~/games/thegame/home"

Granting your real home in [filesystem] turns the private home off entirely.

[hooks] ​

KeyTypeWhen
pre_launchlist of commandsBefore the target starts. Non-zero exit aborts the run
post_exitlist of commandsAfter the target terminates. Gets BAILEY_EXIT_CODE

on_violation was removed: nothing could trigger it. A config that still sets it is accepted with a warning rather than rejected. See lifecycle hooks.

Commands run through sh -c and accumulate across layers, running in layer order.

Path handling ​

  • Relative paths resolve against the directory of the config file containing them.
  • ~ and ~/... expand using $HOME.
  • ${VAR} expands from the environment, which is how a shared profile can name something like ${XDG_RUNTIME_DIR}. It expands in env.set values too, since most of what goes there is a path. An unset variable expands to nothing, leaving a path that matches nothing rather than one that matches something unintended.
  • ${PWD} is the directory the run was launched from, asked of the kernel when the shell has not exported it. A profile uses it to grant "wherever I am", which is what lets a per-program policy work without a config file in every project. Launch from a directory you actually want granted: from your home, it grants your home.
  • . and .. are collapsed lexically, so different spellings of one path merge into a single grant.

Errors ​

ErrorCause
failed to parse configNot valid TOML, or an unknown key
invalid configA bad value, such as an unknown access character or size suffix
conflicting directivesOne layer both granted and denied the same path
failed to read configThe file named by --config does not exist or is unreadable

Released under the MIT or Apache-2.0 license.