Filesystem policy¶
Control which directories a tool can read or write — using bwrap, without a daemon.
By default a tool invoked through ixt can read and write anywhere your user account can. ixt tool config <target> fs restricts that at the filesystem level, using bubblewrap (the same sandboxing layer used by Flatpak).
Works best combined with env policy: lock the environment first, then lock the filesystem.
Requires bubblewrap
Install it once: sudo apt install bubblewrap / sudo dnf install bubblewrap / sudo pacman -S bubblewrap / sudo apk add bubblewrap.
If bwrap is not found at exec time, ixt exits with a clear install message.
Runtime only
Filesystem policy applies when the installed command runs through its ixt shim. It does not sandbox package installation, npm lifecycle scripts, or ixt lifecycle hooks. It also does not block network access; combine it with OS-level network controls if that matters.
How it works¶
When a filesystem policy is active, the shim no longer calls os.execve on the real binary directly. Instead it builds a bwrap invocation and execs into it:
bwrap --unshare-pid --as-pid-1 --proc /proc --die-with-parent
[preset binds: app-common | app-minimal | (none = expert)]
[user extras: --ro-bind / --bind / --tmpfs]
--clearenv [--setenv K V …]
-- /real/binary args…
- The sandbox constant (
--unshare-pid --as-pid-1 --proc /proc --die-with-parent) is applied for every preset exceptall. PID isolation closes/proc/*/environreads inside the tool.--as-pid-1makes the tool itself PID 1 in its namespace, so there is no separate reaper holding the host environ. - The filesystem is configured via bwrap bind mounts and tmpfs overlays
- The environment is set via
--clearenv+--setenv(so env policy applies inside the sandbox too) - The shim passes the filtered env to
execve(bwrap, …)— notos.environ— so even/proc/<bwrap>/environfrom outside the namespace is clean (defense in depth). - bwrap replaces the shim process — no daemon, no persistent process
Quick start¶
# 1. Activate the standard preset — everything read-only except cwd and /tmp
ixt tool config ruff fs base app-common
# 2. Add a write destination the tool needs
ixt tool config ruff fs rw ./reports
# 3. Hide sensitive directories with a scratch space
ixt tool config ruff fs scratch ~/.aws
ixt tool config ruff fs scratch ~/.ssh
# 4. Check the result
ixt tool config ruff fs list
fs base — the starting layout¶
| Value | What bwrap sets up | Use case |
|---|---|---|
all |
No filesystem sandbox (default) — direct exec or env-only via bwrap | Tool you trust |
app-common |
Entire host filesystem bound read-only, cwd rw, /tmp scratch, env_dir restored on top |
Most CLI tools |
app-minimal |
Strict minimum: env_dir + system libs + ld.so configs + /dev/null + /dev/urandom |
Unfamiliar tool, no host data needed |
none |
Empty sandbox — user mounts everything via fs ro/rw/scratch |
Expert mode, full control |
app-common in detail:
--ro-bind / / ← everything visible but read-only
--bind $CWD $CWD ← current working directory is rw (always implicit)
--tmpfs /tmp ← fresh empty /tmp, discarded on exit
--ro-bind <env_dir> <env_dir> ← restores env_dir on top of /tmp shadow
--dev /dev
The trailing --ro-bind <env_dir> is important when IXT_HOME lives under /tmp (tempfile setups, ephemeral CI runners) — without it, the --tmpfs /tmp would shadow the tool's own binary.
Tip
app-common is the right starting point for formatters, linters, and code generators: they need to read your source tree (covered by the RO root) and write results in the project (covered by the implicit cwd bind).
app-minimal in detail:
--ro-bind <env_dir> <env_dir> ← the tool's own files
--ro-bind /usr/lib /usr/lib32 /usr/lib64 ← system libs (each if present)
--ro-bind /lib /lib64 ← (each if present)
--ro-bind /etc/ld.so.cache /etc/ld.so.conf* ← dynamic linker config (each if present)
--dev-bind /dev/null /dev/null
--dev-bind /dev/urandom /dev/urandom
Not mounted: $HOME, cwd, /etc complete, /tmp, /usr/bin. Tools whose entry point shebangs to a path outside <env_dir> and the system libs (#!/usr/bin/env python3, #!/usr/bin/node, …) will fail to start under app-minimal. Add the relevant paths explicitly with fs ro <path>, or promote to app-common for full host visibility.
When to pick app-minimal vs app-common
Pick app-minimal for tools that should never read your source tree, your /etc, or anything user-private — for instance a self-contained binary or a tool you want to run with the smallest practical filesystem view. Pick app-common for everything else: linters, formatters, generators, dev tools.
none in detail: zero preset binds. Only the sandbox constant (PID namespace + private /proc + --die-with-parent) plus whatever you list via fs ro/rw/scratch. Useful when you want absolute control over the sandbox layout — for example to mount only /usr/lib and a single project directory.
If you set fs base none without adding any fs ro/rw/scratch, the shim refuses to launch with a clear error message — an empty sandbox cannot reach the tool's binary anyway.
Auto-promotion: fs base defaults to app-minimal when you add an extra¶
If fs_base is still all (the default) and you call fs ro/rw/scratch <path>, ixt auto-promotes the base to app-minimal and prints a one-line warning:
note: fs_base auto-set to "app-minimal" — tool binary + libs only,
your project files are NOT visible. Add 'fs rw .' or
'fs base app-common' to widen access.
The reasoning: with fs_base=all, the shim has no preset binds — adding just fs ro X produces an empty sandbox that can't even reach the tool's own binary. Auto-promotion gives a sane default; explicit promotion to app-common is one command away.
The promotion is idempotent: if fs_base is already non-all, the warning is not repeated.
fs ro — extra read-only paths¶
Adds additional read-only bind mounts on top of the base.
ixt tool config ruff fs ro /data/shared # absolute path
ixt tool config ruff fs ro ../sibling-repo # relative — resolved at exec time
ixt tool config ruff fs ro /mnt/data /opt/ref # multiple at once
Relative paths are resolved to absolute at exec time (using the cwd when the tool is invoked), not at config time.
fs rw — extra read-write paths¶
Adds read-write bind mounts. Use for output directories the tool needs to write to.
With app-common, the cwd is already rw — fs rw is only needed for paths outside the cwd.
fs scratch — empty writable tmpfs¶
Mounts a fresh empty tmpfs at the given path. The real content is hidden; the tool sees an empty writable directory. Writes are silently discarded when the process exits.
ixt tool config ruff fs scratch ~/.aws # hides real ~/.aws
ixt tool config ruff fs scratch ~/.ssh ~/.config/gh
Use scratch for directories that contain secrets the tool has no legitimate reason to access. The tool can still create files there without errors — they just vanish on exit.
scratch ≠ permission denied
The path exists and is writable. The tool won't get an error accessing it — it will just see an empty directory. This is intentional: tools that probe for optional config files silently get "nothing configured" rather than a crash.
Combining env and fs policy¶
Both policies apply together inside the same shim. Configure them independently:
# env: restrict what vars the tool sees
ixt tool config ruff env base os-common
ixt tool config ruff env allow '*RUFF*'
# fs: restrict what directories the tool can touch
ixt tool config ruff fs base app-common
ixt tool config ruff fs scratch ~/.aws ~/.ssh
# inspect both
ixt tool config ruff env list
ixt tool config ruff fs list
Debugging — IXT_SHIM_DEBUG¶
[ixt shim: ruff] env base='os-common' allow=['*RUFF*'] deny=[]
[ixt shim: ruff] env passed (47 vars): HOME LANG PATH RUFF_CACHE_DIR …
[ixt shim: ruff] fs base='app-common' ro=[] rw=['./reports'] scratch=['~/.aws', '~/.ssh']
Persisting in ixt.toml¶
[tools.ruff]
env_base = "os-common"
env_allow = ["*RUFF*"]
fs_base = "app-common"
fs_scratch = ["~/.aws", "~/.ssh"]
fs_rw = ["./reports"]
Reference¶
| Action | Arguments | Description |
|---|---|---|
list (default) |
— | Show the current fs policy |
base |
all\|app-common\|app-minimal\|none |
Set the base filesystem layout |
ro |
PATH ... |
Add read-only bind paths (auto-promotes fs_base from all to app-minimal if needed) |
rw |
PATH ... |
Add read-write bind paths (idem auto-promotion) |
scratch |
PATH ... |
Add scratch paths (empty rw tmpfs, real content hidden, writes discarded — idem auto-promotion) |
reset |
— | Restore fs_base="all" and clear all ro/rw/scratch |
Limitations¶
- Requires bubblewrap. If
bwrapis not onPATHat exec time, the shim exits with an install message. fs policy cannot be enforced without it. - Linux only. bwrap uses Linux namespaces. On macOS and Windows, fs policy is saved to
ixt.jsonbut not enforced at runtime. app-commonbinds the entire host root. This means/proc,/sys,/dev(devices), and all mounted filesystems are visible read-only. Usescratchto hide specific sensitive paths.app-minimalis strict by design. Tools shebang-resolving to/usr/bin/python3or/usr/bin/nodewill not start without an explicitfs ro /usr/bin. This is intentional — minimal means "exactly what one program needs to run", and host-wide tooling defaults belong inapp-common.- No network isolation. fs policy controls filesystem visibility only. It does not block outbound connections.
- Landlock fallback (kernel ≥ 5.13, no bwrap required) is a future security track, not part of the current fs-policy contract.