Sandboxed package-manager runner. composer install and npm install execute
arbitrary package code (postinstall scripts, composer plugins) with full access
to your machine — boxme runs them inside a microsandbox
microVM instead, then shows you exactly what they did before anything touches
your repo.
boxme composer install
boxme npm i some-packageWhat happens:
- A microVM boots from the
boxme-basesnapshot (PHP/Node versions matched to your host, your project mounted read-only with a throwaway writable layer on top, composer/npm caches on persistent volumes). - The command runs fully interactively — prompts and progress bars work.
- Inside the guest, tcpdump records every DNS lookup and outbound TCP SYN.
- A full-screen review shows:
- Files: the expected write-set (
vendor/, lockfiles) summarized, anything outside it itemized with inline diffs; - Network: every destination contacted, classified known registry vs unexpected;
- Outside: anything the command wrote outside
/workspace(a binary in/usr/local/bin, a key in/root/.ssh, ...) — a supply-chain red flag. These are reported only, never copied back.
- Files: the expected write-set (
- Only on
a(approve) is the result copied back into your repo.qaborts and nothing lands.
Supply-chain guards baked into the base image: the composer
innobrain/soak-time plugin and npm min-release-age=7 block dependencies
younger than 7 days.
curl -fsSL https://raw.githubusercontent.com/kauffinger/boxme/main/install.sh | shThis downloads the prebuilt binary for your platform (Apple Silicon macOS, Linux
x86_64, Linux arm64) into ~/.local/bin and verifies its checksum. Override the
location with BOXME_INSTALL_DIR, or pin a version by passing the tag:
curl -fsSL https://raw.githubusercontent.com/kauffinger/boxme/main/install.sh | sh -s -- v0.1.0Or build from source (needs a Rust toolchain):
cargo install --path . --lockedThen build the base snapshot once (~10 min):
boxme setup # 32 GiB writable guest disk by default
boxme setup --disk 64 # bump it for very large repos (sparse, ~free until used)boxme setup also downloads the microsandbox runtime (the msb binary and
libkrunfw) into ~/.microsandbox on first run, so you don't need a separate
microsandbox CLI install — the runtime version is pinned to the SDK boxme links.
boxme needs hardware virtualization to boot the microVM — an Apple Silicon Mac, or Linux with KVM.
boxme composer install
boxme composer require foo/bar
boxme npm ci
# Global flags go BEFORE the command (everything after `composer`/`npm`
# is passed through verbatim):
boxme --strict composer install # deny-by-default network: registries only
boxme --learn composer install # re-open the host picker to re-curate
boxme --keep npm install # keep the VM around afterwards
boxme --memory 4096 --cpus 4 composer update
boxme -a composer install # --composer-auth: inject ~/.config/composer/auth.json as unleakable secrets
# The whole project is mounted read-only and the existing vendor/ and
# node_modules/ stay visible, so an incremental command only does incremental
# work (for a clean install, remove them on the host first, or use `npm ci`):
boxme composer require vendor/package # reuses the existing vendor/
boxme npm install some-package # reuses the existing node_modules/
# Pass environment variables into the guest (private registries, auth):
boxme -e COMPOSER_AUTH composer install # copy host value
boxme -e NPM_TOKEN=xyz npm install # set explicitlyAnything you pass with -e is visible to the package code running in the
sandbox — a malicious postinstall could read it and try to send it somewhere.
The Network tab shows every destination contacted; --strict limits where
anything can go.
If your composer.json pulls from private repos (Flux, a private Satis, a
GitHub PAT for private packages), composer needs credentials — but putting them
in the box with -e COMPOSER_AUTH hands the raw tokens to every postinstall
script and plugin that runs there. --composer-auth (-a) does it safely:
boxme --composer-auth composer install
boxme -a composer install # same thing, shorthandIt reads your global ~/.config/composer/auth.json and injects each credential
as a microsandbox secret. The guest only ever sees an opaque placeholder;
the real token lives in a host-side TLS proxy that splices it into the outgoing
request — and only on a connection to that credential's own host. Package
code (and the Claude agent, and dev-server tooling) can never read the real
value, and if any of it copies a placeholder and aims it at a different host,
microsandbox blocks the request. So a leaked credential can travel exactly one
place: the host it already authenticates.
Reachability is unchanged — a private repo still has to be allowed through the
network policy (.boxme/allow, via the review or --learn) to be contacted at
all. --composer-auth only attaches the credential once the host is reachable.
github.com is a built-in registry, so a github-oauth token works right away.
The flag also works with boxme claude (so the agent can install your private
packages) and boxme dev (which installs deps in-guest). It only injects
composer credentials; an npm install run ignores it.
Without a terminal there is no review TUI, so plain boxme composer install
fails fast and points here. --json replaces the TUI with a two-step flow that
keeps the same guarantee — nothing touches your tree until an explicit approval:
boxme --json composer install # run + report; changeset staged, NOT applied
boxme apply # step 2a: copy the staged changeset into the project
boxme discard # step 2b: drop it instead
boxme allow some-host.com # trust a blocked host without the TUI, then re-runStep 1 runs the command under deny-by-default enforcement (registries plus
.boxme/allow; --learn is TUI-only), streams the command's output to stderr,
and prints a JSON report to stdout: every changed file (with diffs, partitioned
expected vs unexpected), every network contact (registry / allowed /
blocked), anything written outside /workspace, and the guest exit code. The
changeset is staged under .boxme/pending/ (self-gitignored) and the report is
kept next to it, so whoever runs boxme apply can still see what they are
approving. The staged changeset is a plain gzipped tar with project-relative
paths — tar tzf .boxme/pending/changeset.tgz lists it, tar xzf … -O -- <path>
prints one file — and the report's pending object carries both commands so an
agent can discover them without reading docs.
Exit codes: 0 clean (safe to boxme apply), 1 boxme itself failed, 2 the
command failed inside the sandbox (nothing staged), 3 the command succeeded
but the report has findings — blocked hosts, unexpected files, out-of-workspace
writes — listed in findings for a script to branch on. A typical agent loop:
run with --json; on 3 with blocked_hosts, boxme allow the hosts it
trusts and re-run; on 0, read the report and boxme apply.
boxme dev runs your whole dev stack inside the sandbox and forwards its ports
back to your machine. The default command is composer run dev (Laravel's
concurrently-driven artisan serve + queue + Vite), but anything works:
boxme dev # composer run dev, ports 8000 + 5173
boxme dev npm run dev # just Vite
boxme dev -p 3000 -p 5173 npm run dev # custom ports (HOST or HOST:GUEST)While a boxme dev session is running, open another terminal in the same folder
and drop into the live VM — for migrations, tinker, a one-off build, or just
poking around:
boxme attach # interactive shell in /workspace
boxme attach php artisan migrate # run one command and exit
boxme attach php artisan tinkerattach finds the session by the folder (one dev VM per folder) and connects as
a second shell alongside the running stack — it never tears the VM down. It isn't
dev-only: any running boxme VM works, including one kept with --keep (see
Kept VMs and housekeeping). The
database lives in the guest, so anything stateful (creating the sqlite file,
running migrations) happens here, in the attached shell. Equivalently, chain it
into the dev command itself:
boxme dev bash -lc 'php artisan migrate --force && composer run dev'What it does: boots a writable guest, copies your project in, runs composer install / npm install in the guest (so dependencies get their Linux-native
binaries — no platform mismatch), then runs your command and watches your project
for edits. Every save is synced one-way, host → guest, so the dev server and
Vite HMR see your changes live — but nothing the guest writes is ever copied back.
Your machine stays read-only to the sandbox; the integrity guarantee holds.
Because the install happens in the guest, this is also the cleanest way to develop
a project whose native modules (esbuild, sharp, …) differ between your host and
Linux — you never have to reconcile the two node_modules. Stop the dev server
(Ctrl-C) and the sandbox is torn down.
Files the guest owns aren't synced from the host: node_modules, vendor,
.git, storage, bootstrap/cache, public/build, public/hot. A dev server
often needs to reach a database or external API, so the network policy still
applies — run boxme <pm> install --learn first if you want deny-by-default
enforcement during the session (otherwise egress is open but recorded).
Servers that bind only
127.0.0.1inside the guest (artisan serve, Vite by default) are bridged onto the guest's interface automatically, so the forwarded host ports reach them without any config.
Running boxme dev in several repos at once works — each gets its own VM, named
per folder. If a default host port (8000/5173) is already taken by another
session, boxme bumps it to the next free one and prints what it chose
(host port 8000 busy → using 8001); the app still serves its normal port inside
its own VM.
boxme claude runs Claude Code itself inside the sandbox, then copies exactly
what it changed back into your working tree — as plain uncommitted edits you review
with git diff and commit (or throw away) yourself.
boxme claude # interactive session; exit to copy out the result
boxme claude 'fix the failing test' # one-shot headless runThe interactive session runs in auto mode (--permission-mode auto) — you're
at the terminal, so its classifier can pause for your okay on a risky action. The
headless run is unattended, so it runs with permission checks bypassed (auto mode
would abort a headless run when its classifier blocks a legitimate step); the
sandbox is the safety boundary, and you review the result before you keep it.
The agent operates on your project mounted read-only with a throwaway writable layer on top, same as a package run — so it physically can't touch your machine during the session. When it exits, boxme diffs what it wrote, tears the VM down, and copies the changeset into your working tree:
git diff # review what the agent changed
git checkout . # or throw it all awayNo clean repo required — it works on a dirty tree, or even a directory that isn't a git repo at all.
If the agent changed a file you'd also edited locally, copying out would
overwrite your work, so boxme stops and asks: apply over your edits, put the
agent's work on a boxme/claude-<n> branch instead (your edits left untouched), or
abort. A headless run can't ask, so it takes the branch automatically:
git diff boxme/claude-1750000000~1 boxme/claude-1750000000 # review the branch
git merge boxme/claude-1750000000 # take it when readyThe branch is built in a throwaway git worktree at HEAD, so it's a faithful
"HEAD + exactly what the agent did" — none of your uncommitted work is mixed in,
and your working tree is never touched.
The agent needs a Claude credential, and there's no browser login inside the box (the sandbox only reaches Anthropic's API hosts, not the OAuth login flow), so boxme authenticates from a token. Generate a long-lived one on the host and save it to your keychain:
claude setup-token # walks you through OAuth, prints a 1-year token
boxme login # paste it — stored in your keychain, never in a dotfileboxme login keeps the token in the macOS Keychain (or a 0600 file under
~/.config/boxme on Linux); boxme claude reads it at boot and injects it into
the guest environment only — it never lands in your host shell. boxme logout
removes it.
boxme resolves the credential in order: an explicit -e flag, then your shell's
CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY, then the saved token — so the normal
path keeps the token out of your environment entirely. With none of these,
boxme claude fails fast before booting and tells you to run boxme login. The
token is the same exfil surface as any secret in the box — network enforcement is
the mitigation: only Anthropic's own services (anthropic.com / claude.com, plus
the package registries the agent may legitimately use) are reachable over TCP, so a
leaked token can't be used against anything else.
A copied subscription login (the macOS Keychain blob Claude Code itself writes, or Linux
~/.claude/.credentials.json) is not reused: its access token expires within hours and Claude Code's refresh flow doesn't work headless, so the session would die mid-run. Thesetup-tokentoken sidesteps that — no refresh, valid for a year.
The agent legitimately runs composer/npm, so it keeps the registry baseline on
top of the API host. boxme --learn claude '…' observes with open egress and saves
the hosts it contacted to .boxme/claude-allow (kept separate from the
package-manager .boxme/allow so the two surfaces can't inherit each other's
reachability); later runs enforce it. --strict drops the extras — API host and
registries only.
boxme skills installs two Claude Code skills into ~/.claude/skills (they're
bundled in the binary, so it works offline and tracks your installed version):
boxme skills- fleet-update — say "update all repos in ~/Code for me": Claude runs
composer update/npm updatethroughboxme --jsonin every repo in the folder, applies the clean results, and hands you a per-repo report of anything that needs a human look. - fleet-fix — say "fix the vulnerable deps in ~/Code": a security-only pass
with minimal churn —
composer fix(the innobrain/composer-fix plugin baked into the sandbox image) andnpm audit fix, non-breaking fixes only. Anything that would need a constraint or major bump is reported, never forced.
Both skills inherit boxme's guarantees: every install runs sandboxed, and only clean changesets are applied automatically. Unexpected files are read (never executed) and applied only when plainly benign; out-of-workspace writes and unexplained blocked hosts leave the changeset staged for your review.
Normally a run's VM is torn down as soon as the changeset is staged out. Pass
--keep to leave it running instead — most useful when something went wrong and
you want to poke around the guest exactly as the command left it:
boxme --json --keep composer update # a failed run leaves the VM up for autopsy
boxme ps # list boxme's VMs: name, kind, status, age
boxme attach # shell into this folder's running VM
boxme attach --vm boxme-app-3f2a # …or any VM by name
boxme exec composer why-not php 8.4 # one-off command, no TTY
boxme kill boxme-app-3f2a # stop + remove when you're done
boxme kill --all # sweep every boxme VM, running or notexec is the scriptable sibling of attach: guest stdout/stderr stay separate
streams and the command's exit code becomes boxme's, so agents and scripts can
inspect a kept VM (boxme exec git diff, boxme --json ps) without a terminal.
Without --vm, both target the current folder's single running VM — the dev
session or a kept run — and list the candidates if there's more than one.
boxme never loses track of these: boxme claude also keeps the VM alive
automatically if copying the agent's work out fails, and ps/kill are how you
find and clean up anything that outlived its run. kill only accepts boxme's
own VM names, so it can't remove another tool's sandbox on a typo.
A run is one of two things: observe or enforce. UDP is always blocked apart from DNS; the difference is what TCP can reach.
First run in a project (no .boxme/allow yet) observes: every outbound TCP
connection succeeds and is recorded, and the review's Network tab lets you trust
hosts. Known registries are always allowed and shown for reference; each
unexpected named host gets a checkbox (Space to trust); a bare-IP contact
with no resolved name can't be allowlisted — and is itself worth leaving
blocked. On approve, your picks are saved to .boxme/allow and:
- if the command only ever contacted hosts that are now allowed, the observe run is the clean result and it's copied back as-is — no second run;
- if it touched anything that enforcement would block, the command re-runs under deny-by-default (DNS + registries + your allowlist) and that clean result is what you review and copy back.
Every later run in that project enforces .boxme/allow automatically. When a
new dependency contacts a host the allowlist doesn't cover, that host shows up
blocked in the review's Network tab — mark it with Space, press r, and
confirm: boxme appends it to .boxme/allow and re-runs the command clean under
the updated policy, so the result you review actually had the host available. You
can still pass --learn to re-open the full host picker, or edit the file by
hand. --strict ignores the allowlist and permits only the registries (the
tightest setting) — there blocked hosts are shown for reference but can't be
allowed inline.
.boxme/allow is one entry per line — commit it to share the decision with your
team:
example.com # the domain and every subdomain
=api.example.com # this exact host only
# comments and blank lines are ignored
Real-time "allow this connection? [y/n]" prompting mid-run isn't offered: the
sandbox's network policy is fixed when the VM boots, so trust is decided in the
review (and applied on a clean re-run), not during a run. Path-level rules (e.g. github.com/org/*)
aren't possible either — the URL path lives inside TLS, so the policy only ever
sees the hostname.
↑↓/jk select · g/G first/last · h/l/Tab switch Files/Network/Outside
(1/2/3 jump directly) · Ctrl-d/Ctrl-u half-page scroll ·
Ctrl-f/Ctrl-b/PgUp/PgDn full-page scroll · J/K line scroll ·
c expand a truncated command · Space trust host (observe run) /
mark a blocked host (enforce run) · r allow marked hosts + re-run (enforce run) ·
a approve · q/Ctrl-C abort
A long command line is truncated with … in the header so the tabs stay visible;
c toggles the full command, wrapped below the tabs. Scroll keys act on the diff
on the Files tab and on the list on the Network and Outside tabs. Esc is
deliberately unbound so a reflexive press can't abort a run.
- PHP:
php -vrun from the project dir (so mise/asdf/Herd shims resolve per-directory), falling back tocomposer.jsonrequire.php, then 8.4. The base image ships 8.3, 8.4 and 8.5 side by side. - Node:
node -vfrom the project dir, then.nvmrc, thenpackage.jsonengines.node. Majors other than 24 are installed vianon first use and cached on a named volume.
- The guest gets a git baseline commit of your tree (including uncommitted changes — that's exactly the state the command should operate on). Your host repo is never required to be a git repo and is never touched by guest git.
- Observe vs enforce is covered above; in both, UDP is blocked apart from DNS (composer and npm need nothing else over UDP, and blocking it closes the QUIC/raw-UDP exfiltration path that the SYN-based capture can't observe).
- The composer/npm/Node caches live on named volumes shared across every
project boxme runs. A malicious package can write to those caches, so poisoned
cache content could be picked up by a later run in a different project.
Running the install as a non-root user does not close this: the cache has
to be writable by whoever runs the install, which is the same identity the
package code runs as. The lockfile integrity checks (npm) and
--strictbound the blast radius; per-project isolation is on the roadmap. - Approval is all-or-nothing in v1.
- A nonzero exit from the command still shows the review (red banner) — abort is the natural choice there.
BOXME_DEBUG_NET=/path/file.txtdumps the raw in-guesttcpdump -rtext used for the Network tab, for debugging capture/classification.