Run a coding agent — Claude Code or Codex — on a repo in its own
"skip-permissions" mode, while the macOS sandbox, not the agent, enforces what it
can touch. A wrong rm -rf, a stray git reset --hard, a pip install into your
system: the sandbox turns these from incidents into errors.
s claude # run Claude Code, confined to the current repo (setup below)It's one short, auditable shell script around macOS sandbox-exec (the Seatbelt
sandbox). The agent gets read-write access to your working copy and the bare minimum to
run itself and your tests; everything else is denied by default and opened only when you
ask.
- Native, in place — the agent edits your real working copy with your real macOS toolchain. No Linux guest, no copied files, no syncing changes back.
- Lightweight — a sandboxed process, not a machine: no VM to boot, no image, no daemon; nothing to install beyond one script.
- OS-enforced — the kernel, not the agent, decides what it can touch, so you can let it run unattended in skip-permissions mode.
- Auditable — read the one script before you trust it;
--printshows exactly what any invocation grants, and DESIGN.md explains how it works.
In scope: the agent runs the wrong command — rm -rf in the wrong place,
git reset --hard, clobbering files outside the task, installing junk system-wide.
sandfence turns these from incidents into errors.
Out of scope: a malicious agent, prompt injection, or a poisoned dependency
actively trying to escape or exfiltrate. sandbox-exec shares your kernel and user
account — it's a guardrail, not a containment boundary. The network is open and your
working copy is readable, so code the agent runs (npm install, a build hook) can
read secrets in your repo and send them out. For untrusted code, use a VM or a separate
user account.
Requires macOS on Apple Silicon. Clone it, then make a short s wrapper so daily
use is just s claude:
# Clone — and read it; it's a security tool
git clone https://github.com/sheremetyev/sandfence ~/.config/sandfence
# A short `s` wrapper with the toolchains (and extra paths) you use day to day
cat > ~/.local/bin/s <<'EOF'
#!/bin/sh
exec ~/.config/sandfence/sandfence.sh --brew --rust --node --python --go "$@"
EOF
chmod +x ~/.local/bin/ss is yours to tune: keep only the presets you use, and add -r DIR / -w DIR for
paths you reach for often. Each preset and grant is a real widening of the sandbox.
Auth (once). Each agent keeps its own token in a file the sandbox grants — never the login Keychain, never your shell environment.
- Claude Code — run
s claude, then/login; paste the printed URL into your browser (the sandbox can't open one). It writes~/.claude/.credentials.jsonand refreshes it from then on. - Codex — run
codex loginonce (anywhere); its token lives in~/.codex/auth.json, which the sandbox reads.
| Read-write | the current directory — your working copy |
| Read-only | its own .git / .jj — you drive version control outside the sandbox; the agent can't commit or rewrite history |
| Denied | the rest of $HOME — ~/.ssh, ~/.aws, gh/glab tokens, the login Keychain, ~/.gitconfig credentials, other repos |
Widen it explicitly: -r PATH / -w PATH add a directory or file, and the
--rust / --node / --python / --go presets grant build caches read-write while keeping
registry tokens and PATH-plant vectors denied; --brew makes the Homebrew prefix
readable and runnable but never writable, so brew install and system-wide
pip install stay errors. --print shows exactly what an
invocation grants; DESIGN.md explains why each grant is there.
- macOS on Apple Silicon, default toolchains (Homebrew,
rustup,nvm/fnm+ stocknpm/pnpm, Applepython3, Go from go.dev or brew). pyenv, volta, yarn, and~/sdkGo toolchains aren't auto-detected — grant them with-r/-w(linters' caches too, e.g.-w ~/Library/Caches/golangci-lint). --node: node versions, corepack's cache and pnpm's home are read-only, sonpm -g,pnpm add -g,fnm install,fnm useand fetching a not-yet-cached package manager fail inside by design — the node version is your launching shell's, and a newpnpm/yarnneeds onecorepack prepare pnpm@<ver> --activate(or a firstpnpm --version) outside.pnpm dlxcan't fetch either (its cache backs later unsandboxed runs): use installed deps viapnpm exec. pnpm's store is pinned to$PNPM_HOME/storeinside; astore-dirfrom your pnpm config doesn't apply (if you setNPM_CONFIG_USERCONFIGyourself, addstore-dir=to that file for pnpm ≤10).--brewis read-only wholesale:brew install/upgradefail inside by design. Prefer/opt/homebrew/opt/<formula>overbrew --prefix <formula>in build scripts.--go:go installinto~/go/binfails by design (go run pkg@versioninstead); yourgo env -wsettings andGO*shell variables (except the cache paths) don't apply inside; and fetches that bypass the module proxy (GOPROXY=direct, private modules, a newer Go viaGOTOOLCHAIN) fail — run them outside once.sandbox-execis deprecated by Apple (2017) but fully functional — only the CLI is deprecated; the Seatbelt engine under it still powers the macOS App Sandbox and Chrome's renderer sandbox, so it isn't going away. A future macOS could change the CLI./tmpand/var/foldersare read-write wholesale — don't expect repo isolation there.- Under jj,
.jjis read-only; run read-only commands asjj --ignore-working-copy ….
./test.sh ~/sandfence-tests runs real commands inside the sandbox and asserts each
allow/deny — so you verify what's granted, not just read it. Use a real dir (not /tmp),
and run it in a plain shell (sandbox-exec can't nest).
MIT — see LICENSE.