The OS sandbox
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.
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
| Key | Under kind: os |
|---|---|
kind | os. |
network | Required, 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. |
expose | Optional. Host paths the sandbox may read. See expose. |
image, runtime, kit, resources, unrestricted_network, credentials | Refused: 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:
/usrand/etc, read-only, with/bin,/sbin,/liband/lib64where the host has them. A fresh/proc,/dev, and a/tmpand/dev/shmof their own, capped at 2 GiB and 256 MiB./var/tmpis/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
kraftshim, read-only at/opt/kraft/bin. - The init (
os_init.py) and, when/usr/bin/python3is 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
exposepath, 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
pythonleads to a uv-managed interpreter, so addexpose: [~/.local/share/uv/python]. - A virtualenv outside the worktree, or any prefix whose
bin/scripts need itslib/. Expose the whole prefix, not itsbin/: an exposedbin/mounts only itself, and its scripts then fail to import. - rustup and cargo. Their state lives under
HOME, and the sandbox'sHOMEis its own. SetRUSTUP_HOMEin the repo'senv:besideexpose: [~/.cargo/bin, ~/.rustup], as the example above does, so the proxies in~/.cargo/binfind your toolchains. LeaveCARGO_HOMEunset: cargo then keeps its registry cache in the sandbox's ownHOME, where it can write, and never sees the rest of your~/.cargo, its credentials andconfig.tomlincluded. - Any toolchain whose state lives under
HOME(an nvm, a pyenv, a gradle home) needs the variable that points it there inenv:, and its directory inexpose. - 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/noexposepath covers widens to its prefix. A command in~/.cargo/binmounts all of~/.cargo, with only its credential files shadowed, so a token kept in itsconfig.tomlis readable. Expose~/.cargo/binto 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 theexposepaths 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 ownPATH, and one found nowhere is refused before launch.exposemounts a path but never adds it toPATH: put the command's directory on thePATHthe 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
keyctlnumber 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 ignoresSIGINT,SIGTERMandSIGHUP, and--die-with-parentends 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,SIGTERMandSIGHUPto 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 withPermissionErrorexits 126 (kraft: <command> is not executable). Any other exec failure exits 127, as does the command itself, and printskraft: 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_keyio_uring_setup,io_uring_enter,io_uring_registerbpf,perf_event_open,userfaultfdkexec_load,kexec_file_loadinit_module,finit_module,delete_modulemount,umount2,pivot_root- the newer mount calls:
open_tree,move_mount,fsopen,fsconfig,fsmount,fspick,mount_setattr,open_tree_attr ioctlwithTIOCSTIorTIOCLINUX, the requests that push input into a terminal. Every otherioctlis 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
~/.claudeconfig 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 andkeyctlis denied), but their names can be listed. - User-readable files in
/etcare visible./etcis 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 andmountare denied. - No 32-bit binaries.
- No
resources,credentialsor extraca_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 --userservice stops everykind: ossession 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.1fails. Unix sockets under the session'sTMPDIRwork. - 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./tmpand/private/var/foldersare neither readable nor writable.TMPDIRis a folder of the session's own, not size-capped and removed when the session ends. A tool that writes to/tmpby name fails. Claude Code is pointed atTMPDIRthroughCLAUDE_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 stashandgit pack-refsfail inside the sandbox; listing works. - No fetch or pull inside.
git fetchandgit pullfail withcannot 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, whichghuses) andsecurity verify-certget the host's answer, and OpenSSL-style clients such as Python'ssslread 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
arm64orx86_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.nodefrom 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_TOKENorANTHROPIC_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,/sbinand/System./Library/Developer/CommandLineToolsand the selected Xcode'sContents, since/usr/bin/gitis a shim into them, the one file/Library/Preferences/com.apple.dt.Xcode.plist, the Xcode licence version, andMobileDevice.framework, which Xcode loads until its first launch has run./private/etc,/private/var/db/timezoneand/private/var/db/dyld./dev/null,/dev/zero,/dev/random,/dev/urandom,/dev/dtracehelperand/dev/fd, and no other terminal of yours.- The agent CLI and the command, each
exposepath, 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.