Sandboxed workers

The OS sandbox

Run a repository's workers under the host's own process sandbox, on Linux or macOS, with no image, and what that sandbox mounts, exposes, denies and does not do.

Added in 2.3

kind: os runs a worker as a process of your own machine, inside a sandbox the operating system enforces (bubblewrap on Linux, Seatbelt on macOS), with no container image and no container runtime. This page describes Linux; On macOS says what differs. The agent CLI is the one installed on the host. It sees a small, read-only part of the host, its worktree, and a way to reach Kraft that acts as its own session.

config/repos.yaml
repos:
  - path: /home/you/code/my-service
    sandbox:
      kind: os
      network:
        install: {allow: [crates.io, static.crates.io]}
        runtime: {allow: [api.github.com]}
      expose: [~/.cargo/bin, ~/.rustup]
    env:
      RUSTUP_HOME: /home/you/.rustup

Keys

KeyUnder kind: os
kindos.
networkRequired, and not empty: network: {} is none. Without it nothing would keep the worker from Kraft's API, so the entry does not load. See Network policy.
exposeOptional. Host paths the sandbox may read. See expose.
image, runtime, kit, resources, unrestricted_network, credentialsRefused: kind: os takes no ..., and the entry does not load.

An extra ca_bundle in sandbox.yaml is not applied either. The sandbox trusts the host's own trust store, and the CA files named by the daemon's CA variables (SSL_CERT_FILE and the like) are mounted read-only. A CA path set only in a repository's env: is not one of the daemon's: it must also be listed in expose.

What is mounted

Only these reach the sandbox, and nothing of $KRAFT_HOME beyond them:

  • /usr and /etc, read-only, with /bin, /sbin, /lib and /lib64 where the host has them. A fresh /proc, /dev, and a /tmp and /dev/shm of their own, capped at 2 GiB and 256 MiB. /var/tmp is /tmp.
  • The worktree, read-write, at its resolved path, with the same session files, documents, results and private copy of the repository's refs that a container sandbox gets. The session's own folder (its log and exit file) is never mounted.
  • The item's sandbox home, read-write, as HOME.
  • The directory of the session's channel socket, and the kraft shim, read-only at /opt/kraft/bin.
  • The init (os_init.py) and, when /usr/bin/python3 is missing or older than 3.10, the prefix of the Python that runs Kraft itself, read-only.
  • For the agent CLI and the command, the directory each one's real file lives in (see How a command is found), and each expose path, read-only.

Anything in a command's directory or an expose path named credentials.toml, credentials (cargo's older name for it), .npmrc, .netrc, .pypirc or .git-credentials, up to three directories down, reads as an empty file. That is a backstop, not the boundary: do not expose a tree because its secrets are named differently.

expose

expose lists host paths, each absolute or starting ~/, that the sandbox may read. Use it for a toolchain outside /usr.

Every file under an exposed path is shared, secrets included. Give the narrowest path that works:

expose: [~/.cargo/bin]      # not ~/.cargo, which holds cargo's credentials, its config and the registry cache

A path that does not exist is an error. Some tools need more than their binary:

  • A uv virtualenv. Its python leads to a uv-managed interpreter, so add expose: [~/.local/share/uv/python].
  • A virtualenv outside the worktree, or any prefix whose bin/ scripts need its lib/. Expose the whole prefix, not its bin/: an exposed bin/ mounts only itself, and its scripts then fail to import.
  • rustup and cargo. Their state lives under HOME, and the sandbox's HOME is its own. Set RUSTUP_HOME in the repo's env: beside expose: [~/.cargo/bin, ~/.rustup], as the example above does, so the proxies in ~/.cargo/bin find your toolchains. Leave CARGO_HOME unset: cargo then keeps its registry cache in the sandbox's own HOME, where it can write, and never sees the rest of your ~/.cargo, its credentials and config.toml included.
  • Any toolchain whose state lives under HOME (an nvm, a pyenv, a gradle home) needs the variable that points it there in env:, and its directory in expose.
  • Nix and Homebrew on Linux. Their CLIs are ELF binaries whose loader and shared libraries live in a store outside the system directories, and the sandbox does not follow those. Expose the store path.

Protected paths

A path is refused when it is or holds your home directory, or is, holds or is inside any of $KRAFT_HOME, the run and config directories wherever KRAFT_RUN_DIR and KRAFT_CONFIG_DIR put them, ~/.ssh, ~/.gnupg, ~/.config/gh, ~/.config/glab-cli, ~/.aws, ~/.kube, ~/.docker, ~/.claude, ~/.codex, ~/.local/share/keyrings, ~/Library/Keychains and ~/Library/Cookies. A path whose last component is credentials.toml, credentials, .npmrc, .netrc, .pypirc or .git-credentials is refused too.

The same rule applies to a directory Kraft would mount for a command, and repos.yaml does not load with a protected expose path.

How a command is found

The agent CLI and the repo's commands run under the names they have on the host PATH, not renamed copies. For each one Kraft follows its symlinks and mounts the directory the real file is in, or the install prefix above it when that directory is a bin/. A script's #! interpreter is followed one level. A file under /usr or inside an expose path needs no mount of its own.

  • A bin/ no expose path covers widens to its prefix. A command in ~/.cargo/bin mounts all of ~/.cargo, with only its credential files shadowed, so a token kept in its config.toml is readable. Expose ~/.cargo/bin to keep the mount to that directory.
  • Nothing in the checkout may choose what is mounted. A file inside the worktree is the repo's own: it adds no mount, and its #! line is not followed.
  • A path-named command (./gradlew) whose symlink leads outside the worktree, the system directories and the expose paths is refused: <command> leads to <path>, outside the sandbox's view; add its directory to the sandbox's expose list.
  • A bare command is looked up on /opt/kraft/bin, then the Kraft server's own PATH, and one found nowhere is refused before launch. expose mounts a path but never adds it to PATH: put the command's directory on the PATH the server starts with.

The launcher and the init

Each session starts a small launcher on the host, then bubblewrap, then an init that runs the command as the sandbox's process 1. Both are one stdlib-only Python file, started with -I -S so no environment or current directory can steer them.

  • The launcher joins a new session keyring, so the daemon's keys are not the worker's. On a host whose architecture it has no keyctl number for it refuses to start (exit 125). It passes bubblewrap only the environment variables the session needs, by name (--keep), so a secret never appears on a command line. It ignores SIGINT, SIGTERM and SIGHUP, and --die-with-parent ends the sandbox with it, so a killed launcher takes its sandbox down.
  • The init relays the session's channel to a loopback port, forwards SIGINT, SIGTERM and SIGHUP to the command only when it has left the group the signal already reached, reaps what it leaves, and returns the command's exit code.
  • A command that cannot start sends no started byte, so the session ends as config_error (a launch that failed), not as a task failure. A command the exec refuses with PermissionError exits 126 (kraft: <command> is not executable). Any other exec failure exits 127, as does the command itself, and prints kraft: a command was not found inside the os sandbox; add its directory to the sandbox's expose list.

A launch Kraft refuses to wrap, for example a path-named command that leads outside the sandbox's view, ends the session as config_error with the reason in its log, and does not retry on its own. See Sandbox and Kit stops.

seccomp

A filter for the host's architecture (x86_64 or aarch64) is loaded into every sandbox. It answers EPERM to:

  • keyctl, add_key, request_key
  • io_uring_setup, io_uring_enter, io_uring_register
  • bpf, perf_event_open, userfaultfd
  • kexec_load, kexec_file_load
  • init_module, finit_module, delete_module
  • mount, umount2, pivot_root
  • the newer mount calls: open_tree, move_mount, fsopen, fsconfig, fsmount, fspick, mount_setattr, open_tree_attr
  • ioctl with TIOCSTI or TIOCLINUX, the requests that push input into a terminal. Every other ioctl is allowed.

The short runs Kraft makes before a launch, to ask the agent CLI its version or which hooks it trusts, also start in a session of their own (--new-session), off the terminal a foreground kraft holds.

Any other system-call ABI, 32-bit and x32 included, is answered ENOSYS: no 32-bit binary runs. User namespaces inside the sandbox are disabled too (--disable-userns), and every capability is dropped.

Login by token

The sandbox does not mount the agent's own config, so a login kept in ~/.claude or the keychain does not reach a worker. Use a token: CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token) or ANTHROPIC_API_KEY. Both cross by name, as in a container sandbox, and other agents' keys go in env_passthrough. See Give a worker its login.

A server run as a systemd --user service has only the environment its unit carries: put the token in the unit, as Give the service an environment shows.

What it does not do

  • It runs on Linux and macOS, and never as root. On any other system, or when Kraft runs as root, the sandbox refuses to start. The items below are Linux's; macOS's are under On macOS.
  • The agent's own ~/.claude config is absent. The worker starts from the sandbox home, so settings kept there are not the ones you use by hand.
  • Key names are visible in /proc/keys. The daemon's keys cannot be read or changed (the launcher joins a new keyring and keyctl is denied), but their names can be listed.
  • User-readable files in /etc are visible. /etc is mounted whole, read-only.
  • Nix and Homebrew-on-Linux CLIs need expose.
  • Browser tests need --no-sandbox. A browser cannot make its own sandbox here, because user namespaces and mount are denied.
  • No 32-bit binaries.
  • No resources, credentials or extra ca_bundle. No CPU, memory or process limits; no credential held by the proxy; the host's trust store only.
  • A service restart ends its sessions. Restarting a systemd --user service stops every kind: os session it started: the generated unit's default kill mode stops every process of the service, launchers included, and a sandbox dies with its launcher. Kraft restarted from a shell leaves its sessions running, and reattach adopts them.

On macOS

On a Mac, kind: os runs the worker under Seatbelt (/usr/bin/sandbox-exec) instead of bubblewrap, from the same repos.yaml entry. The guarantee that matters holds: a worker reaches Kraft only through its session's channel, and cannot decide its own gates. The fence is weaker than Linux's in these ways, which come first because they change what you can run:

  • No local TCP servers. The worker can connect to nothing on loopback but its session's proxy, and cannot listen on a port. A test suite that starts a server on 127.0.0.1 fails. Unix sockets under the session's TMPDIR work.
  • Other processes' environment and arguments are readable. macOS lets any process of your user read them, and Seatbelt cannot close that. A worker can see another session's env: values, the daemon's agent token and whatever your shells export. The gate guarantee is not affected.
  • The proxy URL carries a per-session token. The proxy listens on loopback, so it answers a request without the token with 407. Other processes of your user can read the token the same way as the environment; other users cannot.
  • Some names are shared within your user. Python's /psm_ shared-memory and /mp- semaphore names are global to your processes, and guessable like the environment.
  • No private /tmp. /tmp and /private/var/folders are neither readable nor writable. TMPDIR is a folder of the session's own, not size-capped and removed when the session ends. A tool that writes to /tmp by name fails. Claude Code is pointed at TMPDIR through CLAUDE_CODE_TMPDIR.
  • No private copy of the refs. The worker writes its own branch in the repository, and Kraft checks it after the session and puts it back if it holds anything but a commit. A branch moved on the host during the session is not compared first. Creating a branch or a tag, git stash and git pack-refs fail inside the sandbox; listing works.
  • No fetch or pull inside. git fetch and git pull fail with cannot lock ref 'refs/remotes/origin/...': the remote-tracking refs are in the repository's read-only common directory. Kraft fetches and rebases on the host.
  • A mach-service allowlist. The worker reaches the system services a real agent CLI needs, and not LaunchServices, Apple Events, the pasteboard or the keychain. A CLI that needs another service fails until Kraft adds it. TLS is known to work through the system's trust service, which is allowed: a Go CLI's verifier (checked with Go's crypto/x509, which gh uses) and security verify-cert get the host's answer, and OpenSSL-style clients such as Python's ssl read the host's CA files.

Every commit prints error: Unable to create '.../packed-refs.lock': Operation not permitted. That is expected and harmless: git tries to delete pseudo-refs that do not exist at the end of a commit, and the commit lands.

What it needs

  • A Mac of arm64 or x86_64, with /usr/bin/sandbox-exec (it ships with macOS), Kraft not running as root, and Kraft not itself inside a sandbox.
  • Homebrew CLIs need expose: [/opt/homebrew]. Their libraries are not followed, as a Linux binary's are not. node from Homebrew is one. On an Intel Mac Homebrew lives in /usr/local, which is not readable otherwise: expose it.
  • A token, not a login. The keychain is denied, so use CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY. A server run by launchd has only the environment its plist carries: Give the service an environment.

What the worker sees

The profile denies everything and allows back what a session needs. The worker can read:

  • /usr (not /usr/local), /bin, /sbin and /System.
  • /Library/Developer/CommandLineTools and the selected Xcode's Contents, since /usr/bin/git is a shim into them, the one file /Library/Preferences/com.apple.dt.Xcode.plist, the Xcode licence version, and MobileDevice.framework, which Xcode loads until its first launch has run.
  • /private/etc, /private/var/db/timezone and /private/var/db/dyld.
  • /dev/null, /dev/zero, /dev/random, /dev/urandom, /dev/dtracehelper and /dev/fd, and no other terminal of yours.
  • The agent CLI and the command, each expose path, the host's CA files and Kraft's own Python.

It reads and writes its worktree, its sandbox home and its TMPDIR. Nothing else of /Users, /Volumes, /Applications, /opt or /Library is readable, though stat works anywhere. ~/Library/Keychains and ~/Library/Cookies are protected paths.

It can signal only processes of its own sandbox, and nothing it starts outlives the session: Kraft ends every process of the session when it exits, including ones that left the process group. A process it cannot end is logged and recorded as a sandbox_session_outlived event with its pid.

Copyright © 2026