Stop running lifecycle scripts on every install
Published on

Introducing Phased Package Installations

Authors

Every time you run npm install, you're executing arbitrary code from potentially thousands of packages and package authors. "Install scripts" are run automatically, with full access to your system before you've even had a chance to review what's being installed. Unfortunately, this "download anything and run everything" model has been a security blind spot for years; that ends today.

Today, we're excited to introduce Phased Package Installations, a fundamental reimagining of how package installation works. By creating two distinct phases ("install" & "build"), vlt gives you unprecedented control over which packages can run code in your project.

The Problems with the Legacy Installation Model

Traditional package managers conflate two distinct operations:

  1. Installation - Resolving, downloading, and extracting packages into node_modules
  2. Building - Running scripts (preinstall, postinstall etc. - often referred to as "lifecycle scripts") and linking binaries to node_modules/.bin

When you run an install command with traditional package managers both steps happen automatically and atomically. You can't download packages without also executing their scripts. This "trust-by-default" model creates a few problems:

  • Opaqueness: Scripts are run before you know what they are or how they got there
  • Expediency: Compromised dependencies have the opportunity to execute arbitrary code immediately

The Problems with the Current Solutions

  • All-or-Nothing: traditional package managers tried to solve this by implementing flags like --ignore-scripts which take an all-or-nothing approach with no nuance
  • Lack of Context: project's like pnpm & bun have gone further by implementing script configurations that allow for finer-grained control with the listing of blessed package names & versions but lack context regarding their associative location within the graph; essentially treating all matching instances of those dependencies the same irregardless of their edges/ancestry or associative metadata

Enter Two-Phased Package Installation

As of v1.0.0-rc.1, the vlt install command now runs vlt build under-the-hood and both now support our Dependency Selector Syntax; install via. an --allow-scripts=<selector> flag and build via. a --target=<selector> flag.

By default vlt install's --allow-scripts selector value is ":not(*)" (ie. allow no dependencies to run their scripts). If this is your preference - as it is for many - then you can likely skip running the vlt build command altogether.

Alternatively, if you want to run scripts for all, safe dependencies, you can explicitly run the new vlt build command after a successful vlt install and it will run the scripts for all dependencies that aren't flagged as malicious (ie. the default --target value is ":scripts:not(:built):not(:malware)").

We believe this change and the corresponding default configurations add a powerful layer of protection and control back to the installation process.

# Phase 1: Download and extract packages with no dependencies running scripts
$ vlt install

# Phase 2: Optionally run lifecycle scripts for all, unflagged dependencies
$ vlt build

This simple change unlocks powerful new capabilities:

  1. Safe-by-Default: We automatically block all install scripts from running by default
  2. Observability: Review the resolved dependency graph & installed packages prior to script execution
  3. Optionality: The Dependency Selector Syntax provides unrivaled granularlity for opting-in/out dependencies

How It Works

Installing Without Scripts

When you run vlt install, packages are downloaded and extracted to node_modules, but no lifecycle scripts execute:

$ vlt install

Your dependencies are now installed, but nothing has run yet. The vlt client will inform you at the end of the install how many packages have lifecycle scripts:

Done in 1474ms

๐Ÿ“ฆ 3 packages have install scripts defined & were not fully built
๐Ÿ”Ž Run `vlt query :scripts` to list them
๐Ÿ”จ Run `vlt build` to run all required scripts to build installed packages.

As an intermediate step, you can now inspect what was installed, navigate the dependency graph using vlt list or vlt query, check for security issues, and decide if/what you want to build next.

TIP: You can also use our browser-based UI to view more information about your project, easily navigate the dependency graph, security insights, source code & much more. Start the server with: vlt serve

Querying Your Dependencies

Before building, you might want to see exactly which dependencies have build scripts:

# See all dependencies with lifecycle scripts
$ vlt query ":scripts"

# Check for dependencies with issued malware alerts
$ vlt query ":malware"

# Find dependencies that have lifecycle scripts that are not from a specific org/scope
$ vlt query ":scripts:not([name^=@myorg])"

Building Selectively

Using the Dependency Selector Syntax you can also control exactly which dependencies can run scripts:

# Build only your trusted tools
$ vlt build --target="#esbuild, #typescript, #rollup"

# Build everything from a specific scope
$ vlt build --target="*[name^=@myorg]"

# Build all except known problematic packages
$ vlt build --target=":not(#problematic-package)"

Default Protection

When you run vlt build with no arguments, it uses a sane default value that protects you automatically from packages already known for containing malware:

# Uses default target: :scripts:not(:built):not(:malware)
$ vlt build

The default target:

  • Only builds packages that have scripts (:scripts)
  • Skips packages already built (:not(:built))
  • Automatically blocks packages with malware alerts (:not(:malware))

Real-World Examples

Trusting Specific Scopes or Packages

If you only want to run scripts from your organization and well-known build tools:

# Install everything but only run scripts from your organization and well-known build tools
$ vlt install --allow-scripts="*[name^=@mycompany]:not(:malware), #typescript, #webpack"

Trusting Direct Dependencies

If you only want to run scripts for direct dependencies:

# Install everything but only run scripts for direct dependencies
$ vlt install --allow-scripts=":root > *"

Security-First Workflow

Audit your dependencies before building:

# Install packages
$ vlt install

# Check for security issues
$ vlt query ":malware"
# โ†’ Lists any packages with known malware

# Check what needs building using the browser-based UI
$ vlt query ":scripts:not(:built)" --view=gui
# โ†’ Opens default browser showing packages with unbuilt scripts

# Build only safe packages from your scope
$ vlt build --target="*[name^=@myscope]:not(:malware)"

Legacy Compatibility

If you want npm's traditional behavior (not recommended), you can opt-in to allowing all dependencies to run their scripts:

# Run scripts for all dependencies
$ vlt install --allow-scripts="*"

Configuration and Persistence

Target queries can be saved to vlt.json for team-wide consistency:

# Save your target policy
$ vlt config set "command.build.target=*[name^=@myorg]:not(:malware)"

Your vlt.json should looking something like:

{
  "workspaces": ["packages/*"],
  "command": {
    "build": {
      "target": "*[name^=@myorg]:not(:malware)"
    }
  }
}
# Now just run build (you'll notice it uses your saved target configuration)
$ vlt build

The :built and :scripts Pseudo-Selectors

You may have noticed in some of the examples above that we've added two new pseudo-selectors to the Dependency Selector Syntax to make build management easier:

:scripts

Matches packages that have lifecycle scripts (preinstall, install, postinstall &/or prepare):

# Find all packages with scripts
$ vlt query ":scripts"

# Find scripts from external dependencies
$ vlt query ":scripts:not(:workspace)"

:built

Matches packages that have already been built:

# See what's already built
$ vlt query ":built"

# Find unbuild packages with scripts
$ vlt query ":scripts:not(:built)"

Build state is tracked in node_modules metadata, making the process idempotent, vlt build only does work that's actually needed.

If you want to learn more about our DSS query language, read the Query Selectors docs page.

Why This Matters

For Individual Developers

  • Peace of mind: Review what's installed before running anything
  • Faster debugging: Skip problematic build scripts during development
  • Better understanding: See exactly which packages need building

For Teams

  • Consistent security policies: Share build targets via vlt.json
  • Compliance: Meet security requirements around script execution

For the Ecosystem

  • Reduced attack surface: Malicious scripts can't run automatically
  • Better security practices: Forces consideration of script execution
  • Supply chain hardening: Makes npm supply chain attacks significantly harder

Best Practices

  1. Always install first: Run vlt install before vlt build
  2. Start restrictive: Begin with narrow targets, expand as needed
  3. Use scopes: Trust packages from known organizations
  4. Leverage the :malware selector: Always exclude malware in your targets when customizing selectors
  5. Persist your policy: Save target queries to vlt.json
  6. Audit regularly: Use vlt query to review what's installed

Try It Today

Ready to take control of your package scripts? Get started:

# Install vlt via curl
curl -fsSL https://vlt.sh/install | sh

# Install vlt via npm
npm install -g vlt@latest

Join the waitlist

Curious to learn more about vlt? Join our waitlist and get early access.

Sign up