π‘οΈ Tight Seatbelt sandbox β the profile starts with (deny default)
π Project-scoped access β only the CWD and explicitly allowed paths
π Secrets stay out of reach β SSH keys, ~/.aws, ~/.ssh/id_*, private dirs
π¦ Declarative JS config instead of sed surgery over a heredoc
π€ Bot identity for git/ssh inside the sandbox
𧨠Fork-bomb guard via ulimit -u
β‘ YOLO mode, safely (--dangerously-skip-permissions)
π macOS only, Node β₯ 18, no runtime deps beyond yargs
npm install -g clabox # exposes the global `clabox` command
# or run without installing:
bunx clabox β¦
npx clabox β¦# Default profile (~/.claude), YOLO mode
clabox run --dangerously-skip-permissions
# A different Claude profile
CLAUDE_CONFIG_DIR=~/.claude_work clabox run --dangerously-skip-permissions
# A named box from ~/.config/clabox/configs/<name>.config.mjs
clabox -b ax-root --dangerously-skip-permissions
# Debugging
clabox generate # build the profile, print the .sb path
clabox profile # just the path (no build)
CLABOX_DEBUG=1 clabox # print profile/config/dir on launch
clabox --helpUnknown flags are passed straight through to claude, so anything after the
command (--dangerously-skip-permissions, --model β¦, etc.) just works.
Three layers, later wins: defaults β environment variables β JS config file.
The config file is looked up in this order: --config /path β
CLABOX_CONFIG=/path β ./clabox.config.mjs (project root) β
~/.config/clabox/config.mjs.
See clabox.config.example.mjs.
clabox --config ./my.clabox.mjs run --dangerously-skip-permissions// clabox.config.mjs
export default {
configDir: '~/.claude_work',
bot: { name: 'workBOT', email: 'bot@work.dev', sshDir: '~/.ssh/workbot' },
network: true,
paths: {
readWrite: ['~/scratch'], // RW on top of project / configDir / tmp
readOnly: ['~/reference'], // RO
exec: ['/opt/tool/bin'], // process-exec
deny: ['~/secret'], // explicit deny (read + write)
},
};You may also export a function (defaults) => config for full control. ~ is
expanded to $HOME.
Keep a directory of named configs and switch between them by name from anywhere:
# ~/.config/clabox/configs/ax-root.config.mjs
clabox -b ax-root --dangerously-skip-permissions-b <name> resolves ~/.config/clabox/configs/<name>.config.mjs (falling back
to a bare <name>.mjs) and loads it like --config β so it wins over --config
/ CLABOX_CONFIG. Override the dir with CLABOX_CONFIGS_DIR. Files named
_*.mjs are treated as shared partials (e.g. _presets.mjs), not boxes.
A box can pin its own cwd so it always targets one project, no matter where you
run clabox from:
// ~/.config/clabox/configs/ax-root.config.mjs
export default {
cwd: '~/projects/my-app', // claude runs here; this dir is the RW project dir
configDir: '~/.claude_axiomus',
};| Variable | Purpose | Default |
|---|---|---|
CLAUDE_CONFIG_DIR |
Claude config/profile dir (multi-account); passed through to claude |
~/.claude |
CLABOX_CLAUDE_BIN |
path to the claude binary |
PATH, then ~/.local/bin/claude |
CLABOX_BOT_NAME / CLABOX_BOT_EMAIL |
git identity | claudeBOT / bot@example.com |
CLABOX_BOT_SSH_DIR |
bot key dir (id_ed25519, config) |
~/.ssh/claudebot |
CLABOX_CONFIG |
path to the JS config file (the --config flag overrides it) |
β |
CLABOX_CONFIGS_DIR |
global dir of named boxes for -b/--box <name> (<name>.config.mjs) |
~/.config/clabox/configs |
CLABOX_CWD |
working dir to run claude in (also the RW project dir); ~ expanded |
β (the shell CWD) |
CLABOX_HOOKS_DIR |
hooks dir (RO + exec inside the sandbox) | β (off) |
CLABOX_DEBUG |
print diagnostics on launch | β |
TMPDIR |
where the generated profile is stored | /tmp |
sandbox-exec runs a process inside a Seatbelt profile that starts with
(deny default) β everything is forbidden unless explicitly allowed.
clabox run β loadConfig() β buildProfile() β <TMPDIR>/β¦sb
β sh -c 'ulimit -u N; exec sandbox-exec -f <sb> env β¦ claude β¦'
| Module | Responsibility |
|---|---|
src/utils/config.ts |
defaults, env, loading/merging the JS config, ~ expansion |
src/sandbox/profile.ts |
assembling the SBPL profile from config (typed helpers subpath/literal/regex/β¦) |
src/sandbox/run.ts |
locating claude/sandbox-exec, generating the profile, launching with bot env + ulimit |
src/cli.ts |
the CLI (run / generate / profile), built on yargs |
Profile path: $TMPDIR/clabox-<dir-name>-<hash>.sb (hash of the absolute
project path β each project gets its own cached profile).
Package managers are autodetected (src/sandbox/profile.ts) and added to the
read/exec sections: Homebrew (/opt/homebrew or /usr/local/Homebrew),
~/.local, Nix (/nix/store).
Read-only: system dirs /System, /usr, /bin, /sbin,
/Library/Frameworks, Command Line Tools / Xcode, tzdata, system and user
Library/Preferences, detected package paths.
Read-write: the project dir (CWD), the Claude config dir (configDir),
/tmp, /private/tmp, /private/var/folders/β¦, ~/Library/Keychains (for
OAuth refresh), plus paths.readWrite from your config.
Network: (allow network*) when network: true (the default).
Explicit deny β wins even over the allows above:
- private dirs:
denyHome(~/Documents,~/Desktop,~/Downloads,~/Pictures,~/Movies,~/Music); - secrets:
denyDotConfigs(~/.aws,~/.gnupg,~/.kube,~/.docker,~/.config) with a carve-out for~/.config/git; - personal SSH keys
~/.ssh/id_*,*.pem,*.keyβ Claude physically cannot read them. Only the bot key subdir (bot.sshDir) is readable.
ulimit -u <ulimitProcs>β fork-bomb guard (0to disable);GIT_AUTHOR_*/GIT_COMMITTER_*β bot name/email from config;- if
bot.sshDir/id_ed25519exists,GIT_SSH_COMMANDis pinned to it (IdentitiesOnly=yes,IdentityAgent=none); - gpg signing disabled,
NPM_CONFIG_USERCONFIG=/dev/null,DISABLE_AUTOUPDATER=1.
bun test # unit + functional (bun:test)
bun run test # full gate: lint + types + unit + sizeThe suite tests the wrapper, not claude:
- Unit β the generated profile text: SBPL preamble, project RW/exec, network toggle, config dir, ssh-key denials, the deny list, extra config paths, hooks.
- Functional β runs real
sandbox-execagainst a generated profile and asserts that reads/writes inside the project succeed while denied paths are blocked. Auto-skipped off macOS or when running nested inside another sandbox.
- macOS only β needs
sandbox-exec(Seatbelt). Formally deprecated, still works on macOS 14/15. - No nested sandbox β you cannot launch the sandbox from inside another
sandbox (
sandbox_apply: Operation not permitted). Run from a bare host. - Keychain is writable for OAuth refresh (otherwise tokens hit 401 after
~24h). For a stricter setup, swap the RW Keychain block for RO in
src/sandbox/profile.ts(the "Keychain access" section).
