Diagnostics
When a sandboxed command fails, the most common question is: which path did the sandbox block? On supported systems, bulle answers it for you. After a failed run, it reads the operating system’s own record of sandbox denials and prints copy-pasteable fixes:
$ bulle ~/project -- cat ~/.gitconfig
cat: /home/vincent/.gitconfig: Permission denied
bulle: the sandbox denied the following accesses during this run:
denied: read /home/vincent/.gitconfig — add --ro ~/.gitconfig
Add the suggested flag and re-run. No configuration inside bulle is needed — the hints appear automatically whenever the OS makes denial records available, and stay silent otherwise.
These hints are diagnostics only. Enforcement is always done by the kernel (Landlock on Linux, Seatbelt on macOS) and works identically whether or not denial logging is available.
macOS
Nothing to set up. The macOS kernel logs every sandbox violation to the unified log, and bulle queries it (log show) after a failed run. Two caveats:
- Reading the log takes a few seconds, so hints may appear with a short delay after a failure.
- Violation records are written asynchronously, and the unified log sometimes redacts file paths as
<private>. Denials from the very end of a run, or with redacted paths, may be missing from the hints.
Linux
Denial hints need two things, both common but not universal:
- A kernel with Landlock audit support — Linux 6.15 or newer (Landlock ABI v7). On older kernels,
bulleenforces the sandbox exactly the same but cannot obtain denial records. - The audit subsystem enabled at runtime. Some distributions ship it on, others off:
| Distribution | To enable denial logging permanently |
|---|---|
| Fedora, RHEL, CentOS, Rocky | Nothing to do — on by default |
| openSUSE | Nothing to do — on by default |
| Debian, Ubuntu | sudo apt install auditd |
| Arch | sudo pacman -S audit && sudo systemctl enable --now auditd |
| Alpine | sudo apk add audit && sudo rc-update add auditd && sudo service auditd start |
| NixOS | Set security.auditd.enable = true; and rebuild |
| Other | Add audit=1 to the kernel command line, or run auditctl -e 1 (until reboot) |
bulle reads the denial records back through journalctl, which requires your user to be able to read the journal. On most distributions the default administrator account already can (via the adm or wheel group); if not, add yourself to the systemd-journal group:
sudo usermod -aG systemd-journal "$USER"
Running auditd does not hide records from bulle: the audit daemon and the systemd journal receive kernel audit messages independently, so the hints keep working alongside a full auditd setup.
Inspecting denials manually
The same records are available directly, with or without bulle:
journalctl --quiet --no-pager _TRANSPORT=audit + _TRANSPORT=kernel | grep blockers=
Each Landlock denial looks like:
audit: type=1423 audit(1729738800.268:30): domain=195ba459b blockers=fs.read_file path="/home/vincent/.gitconfig" dev="vda2" ino=1523541
How suggestions map to flags
| Denied operation | Suggested grant |
|---|---|
| read a file or directory | --ro PATH |
| write, create, delete, or truncate | --rw PATH |
| execute a program | --rox PATH |
| outbound or listening network access | none — noted as restricted by the network policy |
bulle deduplicates repeated denials, abbreviates your home directory as ~, and caps the output at ten hints per run. Hints are only printed after a failed run: a command that succeeds despite probing a blocked path (many tools try optional config files) stays quiet.
~/.ssh is exactly what the sandbox is for.Retrying with added grants
A sandboxed run often fails because one grant is missing. Each hint carries the flag that would have allowed it:
bulle: the sandbox denied the following accesses during this run:
denied: read /home/user/.gitconfig — add --ro ~/.gitconfig
Add those flags before -- in the original invocation and run it again. The sandbox is restarted rather than widened: Landlock cannot extend a live sandbox, and no command line or prompt is stored or rewritten behind your back.
Making a grant permanent
After a run that hit a new denial, bulle prints the fix twice: first as the flags to rerun with, then as the entries to paste into a profile file — generalized, so what you paste is a rule and not one machine’s paths:
bulle: the sandbox denied accesses
bulle: rerun with: --ro '?$CACHE/toolcache/' --rox '?which:shellcheck'
bulle: add to "claude" (~/.config/bulle/profiles/claude.toml):
ro = ["?$CACHE/toolcache/"]
rox = ["?which:shellcheck"]
The first line is for retrying this run right now; the block underneath is for the profile file, which merges into the profile of the same name at load time. Variables ($HOME, $CACHE, $TMP), resolvers (which:, pkg:), and package store roots are restored, and files denied in the same directory collapse into the directory once three of them appear — so the list stays short and keeps meaning the same thing on another machine. The leading ? marks each entry optional: a machine where the path does not exist skips it instead of failing the run.
When the run had no --profile at all — bulle -- some-command — there is no profile file to add to yet, so bulle says what a new one would be named and reminds you that a one-off rerun needs nothing but the flags above:
bulle: the sandbox denied accesses
bulle: rerun with: --ro '?$HOME/.cache/some-command'
bulle: for a one-off, the flags above are enough; to make it permanent, bulle would create a new profile "some-command" (~/.config/bulle/profiles/some-command.toml):
ro = ["?$HOME/.cache/some-command"]
bulle: a shell one-liner (sh -c '...') needs its own commands granted too; add the coreutils profile for ls, cat, and the rest
bulle never writes those entries for you. A denial is evidence that one run wanted an access, not that granting it is safe, and a file that appears in your profile should be one you decided to put there.