Skip to content

fix(security): skip npm lifecycle scripts when installing skill deps - #752

Merged
luongnv89 merged 6 commits into
mainfrom
fix/751-npm-ignore-scripts
Oct 20, 2026
Merged

luongnv89 merged 6 commits into
mainfrom
fix/751-npm-ignore-scripts

Conversation

@luongnv89

@luongnv89 luongnv89 commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Closes #751

Problem

installScriptDependencies (src/installer-core.ts) runs npm install in <skill>/scripts/ whenever the installed skill ships scripts/package.json, after every asm install. npm then runs the preinstall/install/postinstall/prepare scripts of that package and its dependencies. As a result, installing an untrusted skill executed author-controlled code before the user had reviewed or run anything.

Review found that --ignore-scripts alone is not enough. A skill can ship its own scripts/.npmrc, which npm reads as project config, and use other npm settings to run code or plant executables.

Fix

The install now runs:

npm install --ignore-scripts --git=git --allow-git=none --node-options= --global=false --location=user

The same settings are also passed as npm_config_* environment variables. Nested npm processes, for example the one pacote spawns to prepare a git dependency, inherit the environment but not the CLI flags.

Setting Path it closes
--ignore-scripts lifecycle scripts in scripts/package.json and its dependencies
--git=git .npmrc git=./evil.sh swapping npm's git binary
env npm_config_git the same swap inside a git dependency's own .npmrc (nested install)
--allow-git=none git dependencies entirely. Preparing one spawns a nested npm that a skill's .npmrc can hijack
--node-options= .npmrc node-options=--require ./evil.js reaching the nested npm (backstop for npm versions without allow-git)
--global=false --location=user .npmrc global=true / location=global turning the install global and linking skill bins (e.g. a fake git) onto PATH

Behavior change: a skill whose scripts/package.json has git dependencies no longer installs. asm install fails with git dependencies in scripts/package.json are not allowed. npm versions without allow-git (e.g. npm 10) ignore that flag; there the --node-options= and --git=git backstops still block code execution.

Trade-off: dependencies that need a native build step during install (e.g. node-gyp addons) won't compile at install time. That is the right default for untrusted content. A user who trusts a skill can run npm rebuild in its scripts/ directory.

Decision Record

  • Root cause: npm install on untrusted skill content runs lifecycle scripts, and it also honours a skill-shipped scripts/.npmrc. Several of its keys (git, node-options, global/location) lead to code execution or planted executables even with --ignore-scripts.
  • Options considered: (a) --ignore-scripts only; (b) pin each dangerous config key on the CLI and in the env; (c) refuse git dependencies (--allow-git=none) plus CLI/env pins as a backstop; (d) make the scripts/ dependency install opt-in, or run npm with isolated config.
  • Options rejected: (a) is bypassable via .npmrc; each bypass was reproduced on npm 11.19. (d) is a larger design change, deferred.
  • Selected option: (c), chosen by the maintainer. Every reproduced bypass except the global install needed a git dependency, and the global install is pinned separately.
  • Residual risk: pinning keys one at a time is a blocklist, so other .npmrc keys may still redirect fetches (registry, proxy, strict-ssl), though none is known to run code. A skill can also ship symlinks in scripts/ (e.g. package-lock.json pointing at an absolute path) that npm writes through. This predates the PR and is tracked in fix(security): skill install writes through symlinks shipped in scripts/ #753.

Acceptance Criteria Verification

#751 defines no acceptance-criteria section. Its stated goal, that installing a skill must not execute code from it, is checked with real-npm regression tests:

Vector Test Fails without fix
lifecycle scripts does not run lifecycle scripts from the skill's scripts/package.json yes
.npmrc git swap does not let a skill-supplied scripts/.npmrc swap npm's git binary yes (npm 10)
nested git dep .npmrc does not let a nested git dependency's .npmrc swap npm's git binary yes (npm 10)
.npmrc node-options does not let a skill's scripts/.npmrc inject node-options into a nested git dep install yes
.npmrc global / location does not let a skill's scripts/.npmrc (%s) make the install global (global=true, location=global; prefix pinned to the temp dir) yes

Tests

  • npm run typecheck ✅, prettier ✅, CI=true full unit suite 2989/2989 ✅. The installScriptDependencies tests pass on npm 11.19 and npm 10.9.9.
  • The tests set npm_config_ignore_scripts=false because this repo's .npmrc sets ignore-scripts=true, and npm/npx export that to child processes, which would otherwise mask the bug under npm test.

🤖 Generated with Claude Code

luongnv89 and others added 6 commits October 20, 2026 19:23
…751)

installScriptDependencies ran a plain `npm install` in an installed
skill's scripts/ directory, so preinstall/postinstall scripts from an
untrusted skill (or its dependencies) executed at install time. Pass
--ignore-scripts so dependencies are fetched without running code.

The regression test overrides npm_config_ignore_scripts because this
repo's .npmrc sets it and npm exports it to child processes, which
would otherwise mask the bug under `npm test`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
--ignore-scripts alone does not stop a skill-shipped scripts/.npmrc from
setting `git=<path>`; with a git dependency, npm would then execute the
skill's script as its git binary. Pass --git=git on the CLI, which outranks
project .npmrc, and add a regression test for it.

Also make the lifecycle regression test write its marker from a mark.js
file instead of interpolating the path into a shell string, so it stays
meaningful on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a git dependency has a prepare script, pacote runs a nested
`npm install` in its clone. That child does not get the parent's CLI
flags and reads the dependency's own .npmrc, so `git=<path>` there was
still honoured. Pass npm_config_git=git and npm_config_ignore_scripts=true
in the runner env: children inherit it, and env outranks project .npmrc.

Adds an offline regression test using local git+file:// repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A skill's scripts/.npmrc can set node-options=--require ./evil.js, which
npm exports as NODE_OPTIONS to the nested npm that pacote spawns to
prepare a git dependency, so code from the dep runs despite
--ignore-scripts. Refuse git deps with --allow-git=none (argv and env),
and keep --node-options= and --git=git as a backstop for npm versions
that ignore allow-git. An EALLOWGIT failure now says git dependencies in
scripts/package.json are not allowed.

Adds an offline git+file:// regression test and applies prettier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A skill's scripts/.npmrc with global=true or location=global turned the
hardened install into a global one, linking a skill-chosen bin (e.g. a
fake `git`) into the user's global prefix on PATH. npm ignores prefix
from project config, so that prefix is the user's real one. Pass
--global=false --location=user (argv and env); each one alone leaves one
of the two settings open.

The node-options test now asserts the EALLOWGIT rejection only when the
local npm defines allow-git (npm 10 on Node 22 ignores the flag) and
always asserts the marker is never written, which --node-options= holds
on npm 10. The new global-install tests pin npm_config_prefix to the
temp dir so a regression can never link into the real prefix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@luongnv89
luongnv89 merged commit 9052b2c into main Oct 20, 2026
15 checks passed
@luongnv89
luongnv89 deleted the fix/751-npm-ignore-scripts branch October 20, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(security): skill install runs npm lifecycle scripts from untrusted skills

1 participant