Key takeaways
- npm pack --dry-run is not a no-execution mode: in the tested npm 11.6.0 fixture it ran prepack, prepare and postpack in that order.
- Dry-run prevented the final .tgz write, but each hook still had the npm process’s local authority and changed trace.log.
- The generated tarball metadata included the hook-created trace.log even in dry-run, showing that packaging work had already happened before the write was skipped.
- npm pack --ignore-scripts suppressed all three fixture hooks, but it still created the tarball and is not a general filesystem or network sandbox.
- For untrusted or agent-modified packages, inspect metadata statically first and perform any script-enabled pack in a disposable, secret-free, network-restricted builder.
The sharp question: does dry-run mean no package code runs?
A coding agent changes package.json and asks CI to preview the release tarball. npm pack --dry-run sounds like the conservative inspection step: report what would be packed without making the package. The operational question is narrower and more important than the wording: can package lifecycle scripts execute before npm decides not to save the archive?
In the tested npm 11.6.0 path, yes. A synthetic package wrote one marker from prepack, one from prepare and one from postpack. Dry-run left all three markers in the working tree while omitting the final .tgz file. The command avoided one write performed by npm; it did not remove the authority of package-defined commands.
Chronology: prepare became part of packing in npm 4
npm’s scripts documentation says prepare has run before packing during npm pack and npm publish since npm 4.0.0. It runs after prepack and before postpack. The same page describes prepack as running before a tarball is packed and postpack as running after generation but before the archive reaches its final destination.
The current CLI v11 pack page exposes both dry-run and ignore-scripts as separate configuration controls. That separation matters. Dry-run is described as avoiding changes while reporting what the command would do; ignore-scripts is the control documented to prevent package.json scripts from running. The local result makes the practical boundary visible rather than treating either description as a sandbox promise.
The fixture used only local synthetic code
The audit creates a temporary package named aag-pack-probe with one immutable payload.txt file. Its prepack, prepare and postpack commands each append their event name to trace.log using Node’s filesystem API. The files allowlist includes payload.txt and trace.log, so the reported archive contents reveal whether a script-generated file existed when npm assembled the tarball.
No third-party package was installed or executed. The fixture had no dependencies, credentials or external endpoints. This design answers one mechanism question safely: which package hooks run, in what order, whether npm writes the archive, and whether hook output enters the prospective package.
Exact commands and the three-way comparison
The checked-in harness runs npm pack --json, npm pack --dry-run --json and npm pack --ignore-scripts --json from a fresh fixture state before each mode. It deletes the prior trace and tarball, parses npm’s JSON result, checks the filesystem and records the listed archive paths. Run it from the repository root with node scripts/audit-npm-pack-dry-run.mjs.
Normal pack produced trace order prepack, prepare, postpack; wrote aag-pack-probe-1.0.0.tgz; and listed trace.log. Dry-run produced the same trace and listed trace.log but did not write the .tgz. Ignore-scripts produced no trace, did write the .tgz and omitted trace.log. Thirteen deterministic assertions passed.
The surprising result is a narrow write guard
The bundled libnpmpack 9.0.7 implementation explains the behavior. For a directory spec, it invokes prepack unless ignoreScripts is true. It then asks pacote to construct the tarball. Only after that operation does it check whether opts.dryRun is exactly false before writing the tarball buffer to the pack destination. It invokes postpack after the write decision, again guarded by ignoreScripts rather than dryRun.
In other words, dryRun reaches the archive-write branch late. It does not guard the lifecycle calls surrounding package construction. npm’s top-level pack command then inspects and reports the tarball buffer regardless of whether that buffer was saved. This source path matches the observed markers and is bound to the installed files by recorded SHA-256 digests.
prepare is delegated inside tarball construction
libnpmpack visibly invokes prepack and postpack; prepare is not spelled out in that short file. The middle call delegates directory packaging to pacote, and npm’s lifecycle documentation establishes prepare as part of npm pack. The experiment observed prepare between prepack and postpack, which is consistent with that delegated packaging phase.
This article does not claim to map every pacote call site or every package spec. The evidence is a complete observed order for one local directory fixture plus the adjacent npm and libnpmpack control flow. Remote registry, Git, tarball, workspace and publish paths may resolve and execute differently and require their own tests.
Why this matters for agent-generated release work
A coding agent can add or modify lifecycle scripts in the same change that asks a release pipeline to inspect package contents. If the pipeline treats dry-run as inert, the proposed code receives filesystem, environment, process and network authority before a human sees the reported file list. The report is downstream of execution, not a safe preview of code that has not run.
The risk is not limited to malicious intent. A prepare script may compile files, rewrite manifests, download tools, delete stale outputs, contact a service or consume credentials. A dry-run can therefore dirty a checkout, alter later test inputs or trigger an external side effect even though no tarball appears in the destination.
Dry-run metadata can describe a mutated package
The fixture’s dry-run JSON listed trace.log because the hooks created it before npm assembled and inspected the tarball buffer. That is useful when a maintainer intentionally relies on prepare to build distributable files, but it changes what “preview” means: the result describes the post-script package, not the untouched checkout.
Review both inputs and generated outputs. Record the source commit before hooks, the exact script commands, builder identity, resulting file list and archive digest from the real controlled build. A list printed after arbitrary code ran cannot itself establish that the generation process was authorized or reproducible.
ignore-scripts closes this hook path, not every authority path
In the fixture, npm pack --ignore-scripts --json suppressed prepack, prepare and postpack. The resulting tarball contained package.json and payload.txt but not trace.log. This is the relevant static-packaging mode when the immediate requirement is to avoid package.json lifecycle execution.
It is not a containment system. npm still reads the working tree and writes an archive; npm or its dependencies may access caches and configuration; wrapper commands can run before or after it; and another build step may execute tools directly. Keep ignore-scripts as one explicit control inside a least-authority pipeline rather than labeling the whole job safe.
Separate static inspection from executable preparation
Stage one should inspect package.json scripts, files allowlists, ignore rules, lockfile changes and source diffs without running package code. Use a clean checkout and fail when release scripts changed without a named review. An ignore-scripts pack can provide a bounded view of what exists without prepare-generated output, but teams should state that this may not equal the intended production package.
Stage two, when generated artifacts are required, should run the exact pinned preparation and pack commands in a disposable builder. Remove repository and registry write credentials, deployment identities, signing keys and unrelated workspace mounts. Restrict egress to declared build inputs, cap process and time resources, and promote only inspected outputs to the signing or publishing stage.
Treat publishing as a stronger boundary than packing
A successful pack does not authorize npm publish. Publication changes an external registry and can expose a malformed or secret-bearing archive. Build the tarball once in quarantine, inspect its normalized file list and contents, generate provenance or attestations required by the organization, and pass the immutable artifact—not the mutable source directory—to a separately authorized publisher where the tooling permits.
The release identity should not be available while unreviewed lifecycle code runs. If the publishing workflow necessarily rebuilds from source, repeat the script and output checks under the final pinned toolchain and make any mismatch a stop condition. “Dry-run passed” is not evidence that registry credentials were protected from hooks.
Independent evidence supplies context, not this result
The independent Backstabber’s Knife Collection paper assembled a historical corpus of malicious packages across npm, PyPI and RubyGems and analyzed supply-chain stages. It supports the general decision to treat package lifecycle execution as consequential. It does not study npm 11.6.0, dry-run semantics, this fixture or current ecosystem prevalence.
No independent reproduction of this exact npm result was found and verified during this run. The technical conclusion therefore rests on current npm documentation, pinned npm implementation and the checked-in local audit. Teams making a mandatory security guarantee should rerun the harness on their own pinned npm, operating system, workspace shape and wrappers.
What failed and what remains untested
The first exploratory shell probe confirmed the three hook markers but reported npm’s filename without explicitly asserting whether the dry-run tarball existed. The final harness corrected that evidence gap by clearing the destination before every mode and asserting both presence and absence. It also binds the local implementation files before testing, so an npm upgrade fails visibly instead of silently changing the evidence.
The audit did not test npm publish, workspaces, monorepo root hooks, symlinks, ignore-file edge cases, failures inside hooks, signal handling, concurrent packs, Windows, Linux, registry packages, Git dependencies or network behavior. It is not a vulnerability report and does not establish that every side effect survives every wrapper or container.
Decision: inspect statically, then build with disposable authority
Do not approve npm pack --dry-run as a non-executing inspection step for an untrusted or agent-modified local package. Use ignore-scripts for the static phase and label its output as pre-build contents. When prepare output is required, run normal pack in a clean, secret-free, network-restricted builder and preserve the script inventory, logs, generated file diff and tarball digest.
Halt or roll back when a lifecycle script changes unexpectedly, dry-run dirties the checkout, generated files escape the declared output set, the builder reaches an undeclared destination, the archive differs across identical runs, or a toolchain upgrade no longer passes the positive and negative fixture. Resume only after the exact command path and authority boundary are reviewed again.
Copy-ready npm packaging execution record
Complete this record for one package and one pinned release path. A dry-run report is downstream of lifecycle execution unless your exact toolchain proves otherwise.
Entries stay in this browser tab and are not submitted to AccessAllGPT. Blank responses are copied as [Unresolved].
Node and npm exact versions, npm integrity, OS and architecture, package spec, workspace mode and wrapper commit.
Repository commit, clean status, package directory, manifest and lockfile digests, symlink and submodule state.
prepack, prepare, postpack and wrapper commands; diff from last approved release; interpreters and executable dependencies.
ignore-scripts command, initial file list, files allowlist and ignore rules; expected missing generated outputs.
Disposable runner, UID, mounts, environment, secret exclusions, network allowlist, process/time limits and teardown.
Exact pack command, event order, stdout/stderr, filesystem diff, network log, exit status and retries.
Tarball filename, SHA-256, normalized member list, size, generated-file digests and reproducibility comparison.
Artifact promotion path, signing owner, registry identity unavailable during build, publish command and immutable input.
Unexpected script, write, destination, secret access, nondeterminism, archive member or toolchain mismatch.
Inspect only, build in quarantine, approve publication or reject; owner, expiry, known-good artifact and revocation route.
Primary sources
Browse the publication-wide evidence index →
- npm pack documentation, CLI v11npm Docs · Reviewed: Synopsis, dry-run, ignore-scripts and description sections for npm pack · Retrieved · Supports: npm documents dry-run as reporting what pack would do without making changes, and documents ignore-scripts as preventing package.json scripts from running.
- npm scripts documentation, CLI v11npm Docs · Reviewed: Life cycle operation order plus prepare, prepack and postpack definitions · Retrieved · Supports: npm documents prepack and prepare before tarball generation and postpack after generation; prepare has run during npm pack since npm 4.
- npm 11.6.0 pack command implementationnpm CLI source · Reviewed: Pack command parameters, manifest resolution, libnpmpack invocation and output path · Retrieved · Supports: The command forwards flat options, including dry-run and ignore-scripts, into libnpmpack and then reports the generated tarball metadata.
- libnpmpack 9.0.7 pack implementation bundled with npm 11.6.0npm CLI source · Reviewed: Directory-script guards, tarball creation, dry-run write guard and postpack execution order · Retrieved · Supports: The pinned implementation gates prepack and postpack on ignoreScripts, delegates tarball creation between them, and uses dryRun only to decide whether the completed tarball buffer is written to disk.
- Backstabber's Knife Collection: A Review of Open Source Software Supply Chain AttacksDIMVA preprint on arXiv · Reviewed: Abstract, historical package-registry attack corpus, supply-chain stages and stated scope · Retrieved · Supports: Independent researchers documented historical malicious packages and execution opportunities in package ecosystems; this supplies bounded risk context, not evidence about npm pack 11.6.0 or the synthetic fixture.
Limitations
This is a bounded local reproduction on Node v24.10.0, npm 11.6.0, bundled libnpmpack 9.0.7 and Darwin arm64. It used one synthetic directory package and did not test npm publish, package registries, Git or tarball specs, dependencies, workspaces, monorepo wrappers, hook failures, concurrency, Linux, Windows, containers, caches, network access or credential exposure. Source-level review covered the adjacent npm pack and libnpmpack paths but not every delegated pacote implementation path. The independent paper is historical context and does not validate this npm behavior. ignore-scripts suppressed the three fixture hooks but is not a sandbox, malware control, package safety assessment or proof of zero side effects.
Disclosures
AccessAllGPT executed only a synthetic package created for this article. It did not install, accuse, scan or modify a third-party package and did not publish to a registry. npm maintainers did not review the harness or article. No vendor supplied access, private data, payment or endorsement. AccessAllGPT Research is operated by NeuralArc, is independent, and is not affiliated with OpenAI or npm. Publication-wide relationships are listed on the disclosures page.
Further AccessAllGPT guidance
Continue the research
Get evidence-led updates for teams making production AI decisions.