Key takeaways
- In the tested npm ci --ignore-scripts flow, the synthetic install hook stayed silent, but its executable file and default node_modules/.bin symlink were installed.
- Calling the generated .bin entry executed the dependency and wrote the expected marker. The later execution was explicit; npm ci did not silently launch the binary.
- Adding --bin-links=false prevented the convenience symlink, but the package’s cli.js remained under node_modules and ran when addressed directly.
- ignore-scripts is a lifecycle-event control, not a promise that dependency code is absent, non-executable or unreachable by an agent, task runner or npm exec path.
- For untrusted dependency work, combine lifecycle policy with command allowlists, PATH control, least-authority sandboxes and runtime evidence. Roll back when an undeclared package executable starts.
The sharp question: what does “no scripts” leave runnable?
Teams often add npm ci --ignore-scripts to a CI job or coding-agent sandbox and summarize the result as “dependencies cannot execute.” That is broader than the option’s name and broader than npm’s documentation. The flag addresses package.json lifecycle scripts. A package can also declare a bin entry: an executable file that npm places behind a convenient node_modules/.bin command.
My bounded result is simple. npm 11.6.0 suppressed a synthetic dependency’s install hook, then created node_modules/.bin/aag-ci-probe as a symlink to ../aag-ci-bin-probe/cli.js. The install itself did not invoke that command. When I explicitly called the generated entry, it ran and wrote bin-invoked.txt. This is not a bypass of --ignore-scripts; it is evidence that lifecycle suppression and executable availability answer different questions.
Chronology: a frozen install is still a materialization step
npm documents ci as a clean, automated install that requires an existing package-lock.json or npm-shrinkwrap.json, refuses package/lock mismatches, removes an existing node_modules tree and does not rewrite package.json or the lockfile. Those properties improve dependency determinism. They do not convert package contents into inert data.
The same npm ci page lists ignore-scripts and bin-links as separate configuration entries. npm’s package.json documentation separately explains the bin field and says local installs link package executables into node_modules/.bin. The control surface therefore existed before the experiment: one option governs lifecycle events, another governs links, and neither claims to make installed JavaScript impossible to invoke.
The fixture isolates one hook from one command
The checked-in audit creates a local package named aag-ci-bin-probe at version 1.0.0. Its install hook writes install-hook-ran.txt inside the package. Its bin map exposes aag-ci-probe from cli.js; that file writes bin-invoked.txt in the caller’s working directory and prints one fixed line. It has no dependencies, socket calls, credentials, native code, dynamic imports or external URLs.
The harness packs those files into one local tarball with npm pack --ignore-scripts and records SHA-256 ab0e732f1f8f8657473a5e0c3bee1b0e19e1332b97e25ea0afcc896d1be946ab for the observed run. It generates the consumer lockfile with --package-lock-only, then copies the same package.json and lockfile into three fresh projects. Registry mutability and third-party behavior are outside the evidence path.
Flow one: the hook stayed silent and the command appeared
The first project ran npm ci --ignore-scripts --no-audit --no-fund with default bin-link behavior. After npm returned, install-hook-ran.txt was absent. node_modules/aag-ci-bin-probe/cli.js existed. node_modules/.bin/aag-ci-probe also existed, was a symbolic link, and targeted ../aag-ci-bin-probe/cli.js.
That state is the central finding: script suppression succeeded while executable linking also succeeded. A test that checks only for the absent hook marker would report the narrow policy correctly but could support an unsafe operational summary. The installed tree contained a short command name that later tools could resolve.
Explicit invocation crosses the next execution boundary
The harness then invoked the exact generated .bin path as a child process. Standard output was “aag-ci-probe invoked,” and bin-invoked.txt contained “invoked.” This invocation is deliberately separated from npm ci in both code and result reporting. npm did not spontaneously execute the bin during installation.
The useful security boundary is therefore temporal. At install time, ignore-scripts prevented the declared install lifecycle event. At command time, the operating system executed a file that the dependency supplied. A coding agent that sees an unfamiliar test command, a package.json task that resolves local bins, or a wrapper that adds node_modules/.bin to PATH can cross that second boundary without ever changing npm’s install policy.
Flow two: bin-links=false removes convenience, not code
The second project ran npm ci --ignore-scripts --bin-links=false --no-audit --no-fund. Again, the install hook marker was absent. This time node_modules/.bin/aag-ci-probe did not exist. The separate flag did exactly the narrower thing its name suggests.
The package’s cli.js still existed under node_modules because it is package content. The harness invoked that file with the current Node executable and observed the same bin-invoked.txt marker. Disabling links reduces accidental discovery and PATH-based command resolution; it does not filter bin files out of tarballs, revoke read/execute permission, or stop a caller that knows the direct path.
Flow three proves the install hook was viable
A normal npm ci positive control installed the same locked tarball without ignore-scripts. install-hook-ran.txt appeared, the package executable existed, and the .bin symlink existed. This matters because an absent marker in both ignored flows could otherwise mean the synthetic hook was malformed or npm never recognized the package metadata.
Together, the controls distinguish three observations: ordinary ci ran the install hook; ignored ci suppressed it while preserving the executable and default shim; ignored ci with bin links disabled preserved the executable without the shim. The experiment does not generalize beyond those exact flows, but each claimed branch has a positive or negative observation.
npm ci passes both policies into dependency reification
The installed npm 11.6.0 lib/commands/ci.js source has SHA-256 76cacc0ed9bbee8a7c1e5d5656201403a4dd688d31ee141b531c9e85a7e75e9e. Its accepted parameters include ignore-scripts and bin-links on adjacent lines. The command builds Arborist options from current flatOptions, validates the frozen virtual tree against the ideal tree, clears node_modules and calls arb.reify(opts).
After reification, ci reads ignore-scripts again before running the root project’s preinstall, install, postinstall, prepublish, preprepare, prepare and postprepare events. This source explains the top-level lifecycle suppression. It also shows why “ci did not run root scripts” is not equivalent to “reification created no executable links.” Dependency building is delegated with both independent options.
Arborist puts scripts and bins in separate guarded branches
The bundled @npmcli/arborist 9.1.4 rebuild source has SHA-256 0915a24508d04986869407f80ef6f793a85abd1a4785e27b4029efcb7f4921dd. Arborist uses this builder during reify and npm rebuild. Its queue construction inspects each selected package for bin, preinstall, install, postinstall and prepare metadata.
In #build, preinstall runs only when ignoreScripts is false. For linked dependencies, prepare has the same guard. The next branch calls #linkAllBins when binLinks is true. Install and postinstall then sit behind another ignoreScripts guard. The implementation directly matches the observed combination: lifecycle queues can remain unexecuted while the bin queue is linked.
The early return makes the distinction unusually explicit
Arborist’s rebuild method returns immediately when both this.options.ignoreScripts is true and this.options.binLinks is false. If scripts are ignored but bin links remain enabled, it does not take that return. It loads or receives nodes, builds queues and reaches executable linking while skipping lifecycle runners.
This is useful code-review evidence because it prevents an inference based only on CLI output. A quiet npm command can still make intentional filesystem changes. It also means future npm versions should be rechecked at the source and behavior levels: a refactor could move link creation without changing the documentation wording.
This is expected behavior, not an npm vulnerability
npm’s documentation describes ignore-scripts as preventing scripts specified in package.json from running. A bin declaration is package metadata pointing at shipped content, not a lifecycle event. The observed behavior is consistent with that model and with the separate bin-links option. I found no flag bypass, undeclared event, sandbox escape or automatic invocation.
Calling the result a vulnerability would obscure the engineering decision. The failure mode belongs in policy composition: an operator used one execution control and assumed it covered another route. The remedy is to define which dependency-provided programs may be materialized, discovered and launched, then enforce those decisions at install, process and runner layers.
Coding agents widen the gap between installed and invoked
A human usually recognizes that running an unfamiliar command is a separate act. An autonomous coding workflow may infer commands from README text, issue instructions, package scripts, test failures or generated code. Local .bin entries are intentionally easy to call: npm-run scripts prepend node_modules/.bin to PATH, and many task runners assume dependency tools live there.
The risk is not that every package binary is hostile. Linters, compilers and test runners are designed to execute. The risk is allowing a dependency change to introduce or retarget a command while the agent retains repository write access, cloud credentials, signing keys, deployment tokens or unrestricted egress. An ignored lifecycle hook does not constrain those later process capabilities.
Independent practice supports observing behavior in isolation
OpenSSF Package Analysis is independent of npm and this publication. Its stated behavioral questions include which files packages access, which addresses they contact and which commands they run. Its workers dynamically analyze packages inside gVisor containers and collect process and network evidence. That architecture supports the general practice of detonating untrusted package behavior away from production authority.
It does not reproduce this npm 11.6.0 fixture, endorse this conclusion, prove a package malicious or establish prevalence. I found no independent publication testing this exact ci/ignore-scripts/bin-links sequence during the run. The mechanism claim therefore rests on npm documentation, pinned source and the checked-in local audit; OpenSSF is cited only for independent operational context.
What failed and what remains untested
The first harness run accidentally passed node_modules/.bin/aag-ci-probe as an npm subcommand and failed with “Unknown command.” I corrected the harness to execute the generated path directly and reran the entire audit. Reporting that dead end matters: the final marker comes from a real child-process boundary, not from npm interpreting an unknown command.
The audit did not test Windows command shims, Linux, containers, workspaces, global installs, npm exec or npx resolution, PATH poisoning, command-name collisions, scoped packages, native binaries, postinstall-generated bins, file mode edge cases, registry packages, caches, malicious behavior, credentials or network controls. It does not show that --bin-links=false is a sandbox, and it does not measure how often agents invoke newly installed commands.
Decision: separate dependency install from command authority
Use npm ci --ignore-scripts when suppressing lifecycle events is required, but record the claim narrowly. If dependency-provided commands are unnecessary, also disable bin links and keep node_modules/.bin out of inherited PATH. If commands are required, allowlist package name, version, integrity, command path and expected arguments; launch them in a disposable runner without release authority and capture process, filesystem and network evidence.
Stop or roll back when a lockfile change introduces a new bin name, a known command resolves to a different package or digest, a direct node_modules path appears outside the allowlist, execution writes outside declared outputs, contacts an undeclared destination, spawns an unexpected child, or receives a sensitive credential. Reopen the decision after npm, Arborist, platform, lockfile, package version or wrapper changes. “Scripts ignored” should never be the whole command-execution policy.
Copy-ready dependency command authority record
Use this record when CI or an agent installs dependencies with lifecycle hooks suppressed but may later run package-provided tools.
Entries stay in this browser tab and are not submitted to AccessAllGPT. Blank responses are copied as [Unresolved].
Exact Node/npm/Arborist versions, npm digest, OS/architecture, wrapper commit and effective config sources.
Package name@version, resolved source, integrity, lockfile digest, tarball provenance and signer or registry evidence.
Exact ci command, ignore-scripts source and precedence, root/dependency hook inventory, marker evidence and exit status.
Every package bin mapping, installed file, .bin link or shim, target, mode, collision and change from the known-good lockfile.
Approved resolved path and digest, arguments, environment, working directory, input files, caller and expiry.
PATH value and ordering, bin-links policy, npm-run/npm exec wrappers, direct node_modules paths and shell lookup behavior.
UID, mounts, repository permissions, excluded secrets, egress allowlist, process/time limits and disposable teardown proof.
Processes, children, files read/written, network destinations, stdout/stderr, exit status, timings and expected-output comparison.
New or retargeted bin, digest mismatch, undeclared process/write/destination, credential exposure or policy/config drift.
Reject, install without links, allow bounded execution or promote verified output; owner, expiry, cache purge and credential revocation.
Primary sources
Browse the publication-wide evidence index →
- npm ci documentation, CLI v11npm Docs · Reviewed: Description, clean-install guarantees, lockfile requirements, ignore-scripts and bin-links configuration · Retrieved · Supports: npm documents ci as a lockfile-frozen clean install and lists ignore-scripts and bin-links as separate configuration controls; ignore-scripts suppresses package.json scripts while bin-links governs executable links.
- package.json bin field documentation, CLI v11npm Docs · Reviewed: bin field mapping, executable files, local installation links and shebang requirement · Retrieved · Supports: npm documents that local installs create links in node_modules/.bin for package bin entries so commands can be run by npm scripts and other callers.
- npm 11.6.0 ci command implementationnpm CLI source · Reviewed: Accepted configuration, lockfile validation, node_modules removal, Arborist reify dispatch and root lifecycle guard · Retrieved · Supports: The pinned command accepts ignore-scripts and bin-links independently, passes flat options to Arborist reify, and uses ignore-scripts to guard root lifecycle events after dependency reification.
- Arborist 9.1.4 rebuild implementation bundled with npm 11.6.0npm CLI source via unpkg · Reviewed: Early return, build queues, lifecycle guards, binLinks branch, package metadata inspection and executable linking · Retrieved · Supports: The implementation queues bins and lifecycle events together but guards them separately: ignoreScripts gates lifecycle execution while binLinks gates #linkAllBins.
- OpenSSF Package Analysis README at commit c5c4500Open Source Security Foundation · Reviewed: Project purpose, behavioral signals, architecture, sandboxing and captured process and network data · Retrieved · Supports: Independent ecosystem infrastructure treats commands, file access and network destinations as behaviors worth observing and dynamically analyzes packages in isolated gVisor containers; it does not test this npm fixture or establish maliciousness.
Limitations
This is a bounded local reproduction on Node v24.10.0, npm 11.6.0, @npmcli/arborist 9.1.4 and Darwin arm64. It used one synthetic local tarball, one install hook, one JavaScript bin and three frozen-lockfile projects. It did not test Windows shims, Linux, containers, workspaces, global mode, npm exec/npx, PATH attacks, collisions, native binaries, generated bins, registries, caches, credentials, egress or malicious packages. The direct source review covered npm ci and Arborist’s builder, not every reify mixin or wrapper. The OpenSSF source supplies independent operational context and does not validate the experiment. --ignore-scripts and --bin-links=false are configuration controls, not a sandbox, malware assessment or proof that installed dependency code cannot execute.
Disclosures
AccessAllGPT created and executed only the synthetic aag-ci-bin-probe package for this article. No registry package binary, exploit, external endpoint or production credential was used. npm and OpenSSF 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, npm or OpenSSF. Publication-wide relationships are listed on the disclosures page.
Further AccessAllGPT guidance
- npm --ignore-scripts Is Not a Lasting Quarantine
- npm pack --dry-run Still Executes Package Scripts
- npm Install Is an Execution Boundary
- npm 12 Blocks Dependency Scripts—But a Git Dependency Still Ran prepare
- Node’s Permission Model Is a Seat Belt, Not an AI Code Sandbox
- AccessAllGPT Research methodology
- Publication disclosures
Continue the research
Get evidence-led updates for teams making production AI decisions.