Hard-deny classes

The thirteen hard-deny classes procage emits last in every policy (secrets, trust handoff, persistence, privilege, process access, terminal injection and more), how to waive one by name, and how Linux tiers enforce them.

Every policy carries a hard-deny tier: operations a caged child should never perform, whatever else the policy allows, such as reading SSH keys, planting a git hook or registering a launch agent. On macOS it is emitted last in the profile, so no allow rule overrides it. A class can be removed only by naming it in Policy::waive, and every waiver is listed in the plan and the receipt.

use procage::policy::{presets, AbsPath, HardDenyClass};

let workspace = AbsPath::new("/path/to/repo")?;
let mut policy = presets::agent_workspace::v3(&workspace);
// `git init` and `git clone` write hooks and config: allow exactly that class.
policy.waive.insert(HardDenyClass::TrustHandoff);

The classes#

Class (name)DeniesPlatforms
fcntl-modifyfcntl commands that modify a file through a read-only descriptormacOS
privilegesetuid and setgid writes and execution; on Linux no_new_privs, always setmacOS, Linux
service-handoffHanding work to unsandboxed services: Launch Services, Apple events, the pasteboard, XPC service lookups (macOS); the D-Bus buses and the systemd service managers’ sockets, so no systemd-run --user (Linux)macOS, Linux
process-accessOther processes’ task ports, information, arguments and environment (kern.procargs* sysctls), and signals to them; the run’s own processes stay readable. On Linux ptrace, process_vm_*, pidfd_getfd, kcmpmacOS, Linux
rename-anchorsRenaming a writable root or an ancestor of a protected or hidden pathmacOS, Linux
terminal-injectionPushing keystrokes into a terminal (TIOCSTI, TIOCLINUX)macOS, Linux
kernel-surfaceio_uring, bpf, perf_event_open, userfaultfd, keyringsLinux
namespacesCreating or entering namespaces, mount system callsLinux
socket-familiesAF_VSOCK and AF_NETLINK sockets, which bypass socket and file filtersLinux
secretsReading and writing credentials and private data in every homemacOS, Linux
trust-handoffWriting files an unsandboxed tool later runs or reads as configuration; removing or renaming .gitmacOS, Linux
persistenceWriting launch agents, launch daemons and login items (macOS); autostart entries, systemd units and cron directories (Linux)macOS, Linux
container-enginesConnecting to a container engine’s socket (Docker, Podman, containerd, BuildKit, Incus, LXD; on macOS Docker Desktop, Colima, OrbStack, Lima, Rancher Desktop, Podman machines), which would start a container with your files mountedmacOS, Linux

Waive only the class a job needs, for example HardDenyClass::ContainerEngines for a build that runs containers.

On Linux, other processes’ /proc entries are not a class: the base grants no /proc, and a rule or grant that reaches them (on /, /proc, or a link to them) is the approximation proc-other-processes, accepted by name. It exposes their arguments, not their environment (R-25).

The data-driven classes#

secrets, trust-handoff and persistence are lists of paths (version 1), case-folded on macOS:

  • secrets, in every home: .ssh, .aws, .gnupg, .kube, .netrc, .git-credentials, .pypirc, .docker/config.json, .cargo/credentials.toml, .config/gcloud, .config/gh; on macOS also the Keychains, browser profiles, Cookies, Mail, Messages and Safari; on Linux also browser profiles, .local/share/keyrings and .password-store.
  • trust-handoff, in any directory: .git/hooks, .git/config, .gitmodules, .gitconfig, shell startup files (.bashrc, .zshrc, .profile, …), .envrc, .npmrc, .cargo/config.toml, editor and agent directories (.vscode, .idea, .cursor, .claude, .codex, .mcp.json); in every home: .cargo/bin, .local/bin, .ssh/config. Objects, refs, the index and the working tree stay writable.
  • persistence: Library/LaunchAgents and Library/LaunchDaemons in every home and system-wide, and login items (macOS); .config/autostart, systemd user units, /etc/systemd/system, /etc/cron.d, /var/spool/cron and /etc/xdg/autostart (Linux).

On macOS homes are the directories under /Users; on Linux /root, every directory under /home, $HOME, and any you add with CageOptions::with_home. The authoritative lists are the constants in procage_policy::presets.

How Linux enforces them#

Landlock cannot deny inside a tree it allows:

  • Where no rule can reach a class’s paths, Landlock’s default deny enforces the class exactly.
  • Where one can, tier T1 masks the paths that exist at spawn (trust-handoff and persistence paths read-only, secrets unreadable) and pins every existing .git under writable trees. Paths created later are not covered: an approximation, accepted with LowerOptions::accept_spawn_snapshot().
  • Tier T2 refuses a policy whose write rule reaches a class, unless the class is waived. Secrets reached only by read rules are hidden by granting the trees around them entry by entry (accepted the same way). A grant bound to a path that reaches a class path is refused at spawn.

The socket classes#

service-handoff (on Linux) and container-engines deny connecting to sockets under every network setting. On macOS a rule matches the socket a path resolves to, so links such as /var/run/docker.sock are covered. On Linux with the network off, proxy-only or Network::Ip, and no ipc.unix_sockets, seccomp refuses every Unix socket; from Landlock ABI 9, with any of these and ipc.unix_sockets, Landlock refuses connecting outside its paths, so no cover is needed. Otherwise the cage looks for the sockets when it is made and at each spawn, in the system locations and every runtime directory (/run/user/*, $XDG_RUNTIME_DIR, CageOptions::with_runtime_dir):

  • Tier T1 covers each socket it finds and the classes’ directories as a whole (/run/dbus, /run/podman, /run/containerd, the runtime directory’s systemd and podman). A socket bound later elsewhere is not covered: an approximation, accepted with LowerOptions::accept_spawn_snapshot().
  • Tier T2 cannot keep out one socket: while one exists, the cage is refused with a gap naming it, and a spawn is refused if one appeared later. Cage::fit answers the gap with no_unix_sockets() (the network becomes Network::Ip) or by waiving the class.
  • An abstract D-Bus address is scoped out from Landlock ABI 6 (as is every abstract socket outside the cage); below ABI 6 it is a gap.

The lists (version 1; the authoritative constants are in procage_policy::presets):

ClassLinuxmacOS
service-handoff/run/dbus/system_bus_socket, /run/systemd/private; in each runtime directory bus and systemd/private; directories /run/dbus and the runtime directory’s systemd; abstract names starting /tmp/dbus-the Mach services listed above
container-engines/run/docker.sock, /run/podman/podman.sock, /run/containerd/containerd.sock, /run/buildkit/buildkitd.sock, /var/lib/incus/unix.socket, /var/lib/lxd/unix.socket, /var/snap/lxd/common/lxd/unix.socket; in each runtime directory docker.sock, podman/podman.sock, containerd-rootless/api.sock; directories /run/podman, /run/containerd and the runtime directory’s podmanin every home .docker/run/docker.sock, .colima/*/docker.sock, .colima/*/containerd.sock, .orbstack/run/docker.sock, .lima/*/sock/docker.sock, .rd/docker.sock, .local/share/containers/podman/machine/**/*.sock; /private/var/run/docker.sock

The paths are the default locations documented by D-Bus, systemd and the engines. Sockets at other paths, such as an engine named only by DOCKER_HOST or CONTAINER_HOST, are not covered: hide them in your policy.

What remains the harness’s job#

The trust-handoff lists cover known locations. Anything else the child writes (build scripts, generated code) may later be run or trusted by an unsandboxed program: the harness must treat everything the sandbox wrote as untrusted (R-07). The handoff audit lists what a run created or changed.

Edit this page on GitHub