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, project tarred in, 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
# Copy less into the sandbox (faster, smaller guest disk use) — install
# scripts don't read these. The guest rebuilds its own git baseline, so the
# file review is unaffected:
boxme --without-git composer install # -G: skip the .git directory
boxme --without-media npm ci # -M: skip images/video/audio/archives
# 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.
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.