Permissions
Every run resolves to one policy: the paths the command may read, write, or execute, the environment variables it inherits, and whether it may reach the network. This page covers the grants you write by hand, how to inspect the result, and how the operating system enforces it. Profiles bundle the same grants under a name so you do not repeat them.
Filesystem
The workspace is the command’s working directory and writable area. If omitted, it defaults to the current directory. Use --no-workspace when you do not want this automatic read-write grant.
Additional filesystem access is explicit. Use these flags to add paths to the active policy:
--ro path # read-only
--rox path # read-only plus execute
--rw path # read-write
--rwx path # read-write plus execute
--no-workspace # do not automatically grant the workspace read-write access
--rw or --rwx only for paths outside the workspace that the command should be allowed to modify.Everything before -- is policy; everything after it is the command. The first positional argument is the profile, the optional second positional argument is the workspace, and the separator is required when supplying a command:
bulle git ~/repos/project -- git status
Path entries written in a profile can also use variables, globs, and optional/create markers so one entry works on every machine; see Portable profiles.
Environment
Environment variables are also explicit. By default, bulle does not pass your shell environment into the sandbox. Use --env NAME to pass a variable from the parent environment, or --env NAME=VALUE to define one on the fly:
bulle --rox /usr/bin --env HELLO=WORLD -- printenv HELLO
This is important for secrets. A command cannot read OPENAI_API_KEY, GITHUB_TOKEN, or similar variables unless you explicitly pass them.
Three conveniences cover common cases:
- Name globs.
--env 'GIT_*'passes every parent variable whose name matches the glob. Quote the pattern so your shell does not expand it. Globs also work in profileenvlists. - Dotenv files.
--env-file PATHloadsNAME=VALUEentries from a dotenv-style file: blank lines and#comments are ignored, anexportprefix is stripped, an unquoted value ends at an inline#comment, a"double-quoted"value understands\n,\t,\", and\\, and a'single-quoted'value is entirely literal. - Everything except.
--env-all-except SECRET_KEY,GITHUB_TOKENpasses the whole parent environment minus the named variables, for throwaway commands that want your shell environment without specific secrets. It also withholds secret-shaped variable names on its own — anything matching*_TOKEN,*_SECRET*,*_KEY,*_PASSWORD*,*PASSWD*,AWS_*, and a handful of named ones such asGITHUB_TOKENandSSH_AUTH_SOCK— printing the names it dropped on stderr. Name one explicitly with--envto carry it through anyway.
When sources conflict, the most explicit wins: profile entries, then --env-all-except, then --env-file, then --env.
The summary and JSON views list environment variables by name only; neither view prints their values.
Network
Network access is controlled by profiles. The built-in network profile allows it, and the built-in offline profile denies it. On macOS, the network profile also inherits DNS and certificate service bundles that network clients normally need. Built-in agent profiles inherit network access for compatibility with package managers and remote services.
bulle offline --rox /bin -- /bin/ls
bulle codex,offline
In a profile the capability is named network, so allow = ["network"] enables access and deny = ["network"] disables it.
Executables and libraries
An explicit command after -- is resolved before the sandbox starts and its executable is added to the policy automatically:
bulle -- /bin/ls
On Linux, its required shared-library closure is discovered and granted read-only automatically:
bulle -- /usr/bin/git status
This command grant does not add app state files, config directories, caches, secrets, shell environment variables, or a matching profile. Use profiles for agents and other tools that need a larger, repeatable policy.
Profiles can enable these conveniences with add_exec = true and add_libs = true. Boolean settings inherit like other scalar profile settings: an explicit value in a later inherited profile or child profile overrides the earlier value.
Inspecting the policy
Use bulle show to inspect the sandbox policy without running the command. It is a useful safety check before launching an agent or script, especially when combining profiles with extra filesystem or environment grants. Policy text is never injected into the command’s input or agent prompt.
bulle show codex
By default this prints an intent view: for each contributing profile, its own entries exactly as configured — variables such as $HOME unexpanded, and a resolver entry such as r:libs or which:NAME shown by name with a count of what it expanded to on this machine, rather than the machine-specific paths themselves — plus the resolved network mode, the environment variable list, and one summary line for machine-derived grants such as executable discovery’s runtime library scan:
bulle profile "codex" intent (--all for the fully resolved view):
codex:
ro: none
rox: ?which:codex (2 paths)
rw: none
rwx: +$HOME/.codex/
env: none
network: full
environment (resolved keys): HOME, PATH
runtime libraries of codex: 42 paths (--all lists them)
This is what makes bulle show <profile> readable for a profile with a hundred machine-derived grants: the intent view stays a handful of lines regardless of how many paths a resolver or the executable/library scan actually produced. Pass --all to print the fully resolved view instead — every path, symlink alias, and resolution outcome, the same detail --json always carries.
No command is required: without one (and without a configured default_app), the policy is resolved and printed as-is, minus command-dependent grants such as an explicit command binary and shebang interpreter discovery.
The fully resolved view
bulle show --all prints a human-readable permissions summary ending with a resolution table: one line per configured entry showing what it resolved to — granted, skipped, created, or expanded from a which:/pkg: resolver or glob — so “why can’t the agent see X” is answerable from one command. Entries whose resolved path is also granted through another list (for example when $CONFIG and $DATA collapse to the same directory on macOS) are flagged with their effective permission.
resolution:
rox which:codex → /home/user/.local/share/mise/installs/node/22/bin/codex (+1 more)
rwx +$HOME/.codex/ → created (dir) /home/user/.codex
rw ?/dev/tty → granted
JSON output
Stable machine-readable output is available with bulle show --json:
bulle show --json default ~/Desktop --rox /bin -- /bin/ls
{
"backend": "macos-seatbelt",
"workspace_path": "/home/user/Desktop",
"command": ["/bin/ls"],
"ro": [],
"rox": ["/bin"],
"rw": ["/home/user/Desktop"],
"rwx": [],
"env_keys": [],
"add_exec": false,
"add_libs": false,
"mach_lookup": [],
"network": "full"
}
In the bulle show --json example, workspace_path is the directory where the command would run. Because workspaces are granted automatically by default, the command would run with read-write access to /home/user/Desktop, shown in the rw array. The command field is the command that would be executed, and the ro, rox, rw, and rwx arrays show the readable, executable, writable, and writable-executable path grants. The env_keys array lists environment variables that would be passed into the sandbox. The mach_lookup array lists configured macOS Mach services. The network field shows the resolved network state. The backend value depends on your operating system.
Configuration defaults
A [defaults] block in <config>/config.toml (usually ~/.config/bulle/config.toml) supplies values used when the corresponding flag is absent, so bare bulle does the usual thing in a repository:
[defaults]
profile = "claude"
timeout = "2h"
env = ["GITHUB_TOKEN"]
ro = ["?~/.gitconfig"]
Explicit arguments always win: a positional codex overrides the default profile, --timeout overrides the default timeout, and list-valued defaults (env, ro, rox, rw, rwx) are merged with command-line entries taking precedence. Pass --no-defaults to ignore the block entirely.
A [workspace."<absolute path>"] block overrides [defaults].profile for one specific workspace, keyed by its absolute path:
[workspace."/home/me/repos/x"]
profile = "claude,offline"
bulle (or bulle . / bulle DIR) run from that directory uses claude,offline even when [defaults].profile names something else; an explicit profile on the command line still wins over both. This only ever comes from config.toml in the user configuration directory — bulle never reads configuration from inside the workspace itself, which would let a cloned repository choose its own sandbox.
Resource limits
Beyond the wall-clock --timeout, bulle can cap what a run consumes:
On Linux, a nonzero timeout requires a delegated cgroup even when no cgroup resource limit was requested. The cgroup is what lets bulle terminate every descendant, including a child that calls setsid, and it is emptied when the main command exits so daemonized children cannot survive the run. If no cgroup can be created, the run fails before the command starts. macOS has no unprivileged process-tree container with the same guarantee, so nonzero timeouts fail closed there; --timeout 0 remains valid.
| Flag | Caps | Mechanism |
|---|---|---|
--memory SIZE | resident memory, as in 512M or 4G | cgroup v2 |
--cpu PERCENT | CPU use as a percentage of one core, as in 200% | cgroup v2 |
--nproc N | processes in the sandbox | cgroup v2 |
--nofile N | open file descriptors | RLIMIT_NOFILE |
--fsize SIZE | the size of any single file written | RLIMIT_FSIZE |
--cpu-time DURATION | consumed CPU time, as opposed to wall clock | RLIMIT_CPU |
Platform differences
The first three limits require cgroup v2, so they apply on Linux when a cgroup is delegated to your user, and nowhere else. macOS has no equivalent: Seatbelt has no resource controls, and the POSIX limits that look like substitutes are not per-process-tree. RLIMIT_AS caps virtual address space rather than resident memory, which kills runtimes that merely reserve large sparse mappings — Go, the JVM, and Node all do. RLIMIT_NPROC counts every process owned by your user across the whole system, so using it here would throttle your editor and your other shells rather than the sandbox. Silently substituting either one would report a cap that does not do what it says, so bulle declines instead.
The remaining three limits are portable and apply on both platforms. bulle lowers both the soft and hard rlimit in the child, so the command cannot raise the cap again after exec.
--cpu-time measures a different clock than --timeout: an agent waiting for input consumes wall clock but almost no CPU, so --cpu-time 5m --timeout 8h permits a long idle session while still stopping a process that spins. Note that RLIMIT_CPU applies per process rather than to the whole tree — children inherit the limit but each gets its own budget, so a run that spawns many processes is capped less tightly than the number suggests. Neither cgroup v2 nor macOS offers a cumulative per-tree CPU-time cap, so there is no stronger mechanism to fall back on; --cpu caps the rate instead.
When a requested limit cannot be enforced, bulle says so on stderr and runs anyway:
bulle: --memory is not enforced here: macOS has no per-process-tree memory capPass --strict-limits (or set strict_limits = true under [defaults]) to make that a refusal to run instead, with exit code 2. Warning by default keeps a single configuration usable across a Linux workstation and a Mac laptop; --strict-limits suits continuous integration, where an unenforced limit means the run should not proceed at all.
bulle show names the mechanism behind every limit, so whether a cap is real is something you can check rather than infer:
limits:
memory: 4G (cgroup v2)
nofile: 4096 (rlimit)
cpu: 200% (NOT ENFORCED — macOS has no per-process-tree CPU quota)Limits in the configuration
Limits go in a [defaults.limits] block, and may be scoped to a platform. A limit under [defaults.linux.limits] is simply not requested on macOS, so a shared configuration warns about nothing:
[defaults.limits]
nofile = "4096"
[defaults.linux.limits]
memory = "8G"
nproc = "512"
Platform blocks layer over the shared block, so a memory in both means the platform value wins where it applies.
The same file holds the [vars] table used by portable profiles, and the [scratch] table that relocates scratch workspaces.
OS-level sandboxing
bulle builds a policy before the command starts. The policy is assembled from the workspace, selected profile, command-line flags, selected environment variables, network profile settings, executable discovery, and runtime library defaults. Paths are resolved before sandbox setup, and bulle show prints the resulting policy without running the command.
Linux
On Linux, bulle applies the policy with Landlock. Landlock is a kernel feature, not a package to install; basic filesystem sandboxing requires Linux 5.13 or later with Landlock enabled. The Linux backend restricts filesystem access for the process and its children according to the resolved read, write, and execute grants. Rules are installed from descriptors opened atomically without following symlinks, so a grant cannot be repointed between policy validation and sandbox entry. When the resolved network setting is denied, a seccomp filter denies direct socket operations and the complete io_uring syscall API, whose queue opcodes can otherwise perform equivalent network operations.
macOS
On macOS, bulle generates a Seatbelt profile and runs the command with /usr/bin/sandbox-exec. The macOS backend maps the same policy model to Seatbelt rules, including filesystem rules, optional network allowance, and selected Mach service access from configured mach_lookup entries. This is useful for local workflows, but its behavior is not identical to Linux Landlock.
When a run fails because the kernel blocked something, denial diagnostics report which path it was.