Security How-to

Give your agent's npm installs a release-age floor, so it never installs what was published this morning

Three settings, one for npm, one for pnpm and one for Dependabot, keep a coding agent off versions that are hours old. Real output from each, and what the floor can't stop.

Three open wooden doorways flooded with gold light beside a fourth doorway half bricked up

An agent working through a task will run npm install some-package without a second thought, and it will take whatever latest points to at that second. On 21 October that meant vite 8.3.5, which the registry shows was published at 6:38am UTC the same day. Nobody has read that release. No advisory database has had a chance to look at it. The agent doesn’t know or care.

A release-age floor changes the question from “what’s newest” to “what’s newest that’s been public for a week”. It’s one line of config per tool, it’s checked into the repo so every shell the agent opens inherits it, and the output below shows it working on npm, pnpm and Dependabot. The floor doesn’t make a dependency safe. It buys time for someone else to notice a bad release before your agent installs it, and that’s the whole bet.

Set the floor in npm

The option is min-release-age, in days. The npm docs describe it as: only versions that were available more than that many days ago are installed. It sits beside before, which takes an absolute date instead.

Put it in the project’s .npmrc, so it applies to the agent, to CI and to a colleague’s terminal alike:

# .npmrc
min-release-age=7
min-release-age-exclude[]=@myorg/*

The second line exempts your own packages, which you want the moment they publish. min-release-age-exclude takes package names or minimatch globs.

Here’s the difference on a project with an empty package.json, using npm 11.21.1 and --dry-run so nothing is written. On 21 October, with no floor:

npm install vitest --dry-run --no-audit --no-fund
add vite 8.3.5
add vitest 5.0.5

added 35 packages in 6s

Now with the .npmrc above in place:

add vite 8.3.4
add vitest 5.0.4

The registry’s publish times explain it. vitest 5.0.5 went out on 15 October, six days earlier, so it misses a seven-day floor. 5.0.4 went out on 12 October and passes. vite 8.3.5 is a few hours old, so the install falls back to 8.3.4 from 8 October. Note that this applies to the transitive vite as well as the package the agent asked for by name, which is where most of the unread code in a lockfile actually comes from.

Raise the floor to 400 days and the whole tree slides back: vitest 3.2.4, vite 7.1.5. That’s a useful way to see that the setting is a filter on publish time and nothing else. It has no opinion on whether the older version is any good.

What the agent sees when it asks for something too fresh

An agent that pins an exact version it has just read about in a changelog hits the floor head on:

npm install vitest@5.0.5 --dry-run --no-audit --no-fund --min-release-age=7
npm error code ETARGET
npm error notarget No matching version found for vitest@5.0.5 with a date before 10/14/2026, 4:03:45 PM.
npm error notarget In most cases you or one of your dependencies are requesting a package version that doesn't exist.

That second line is misleading, and an agent will take it at face value. The version exists. It’s too new. A model reading “you or one of your dependencies are requesting a package version that doesn’t exist” may well decide the version number is a typo and try a different one, or reach for --force. The first line of the error, with the cutoff date, is the real explanation.

So tell the agent in the context file. A short note is enough:

## Dependencies
- `.npmrc` sets a 7-day release-age floor. An ETARGET error that mentions "with a date before"
  means the version is too new, not missing. Pick the newest version that is allowed, or stop and ask.
- Never pass `--min-release-age=0` or edit `.npmrc` to get around it.

Set the floor in pnpm

pnpm calls it minimumReleaseAge and counts in minutes, not days. A week is 10080. It goes in pnpm-workspace.yaml:

minimumReleaseAge: 10080
minimumReleaseAgeExclude:
  - "@myorg/*"

According to the pnpm docs, the option arrived in v10.16.0, applies to transitive dependencies and defaults to 1440 (one day) from v11 on, so a v11 user gets a one-day floor without writing anything. On v10 the default is 0, which is off. Run on pnpm 10.28.0 with the 10080 setting, pnpm add vitest printed:

dependencies:
+ vitest 5.0.4 (5.0.5 is available)

The “is available” hint is a handy signal for a person and a useless one for an agent, since it will see 5.0.5 mentioned and may go and ask for it. The same context-file note covers it.

pnpm has one more switch worth knowing about. minimumReleaseAgeStrict, added in v11.0.0, decides what happens when nothing in the requested range is old enough. Set to false, pnpm falls back to a version that fails the check. Set to true, resolution fails. The docs say it defaults to true when you set minimumReleaseAge explicitly and false when the built-in default applies. An explicit setting is therefore stricter than the default, which is the opposite of what most people assume. For an unattended agent you want the failure, so set both.

Set the floor in Dependabot

If Dependabot opens version-update PRs for your agent to review or merge, it has its own cooldown block. The GitHub docs list default-days, per-semver-level keys, and include and exclude lists. Without any configuration, version updates already get a default three-day cooldown, so a longer window is a deliberate choice:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "daily"
    cooldown:
      default-days: 7
      semver-major-days: 14
      exclude:
        - "@myorg/*"

Security updates are not subject to the cooldown. According to the docs the option is for version updates only, so a Dependabot PR that fixes a published advisory still arrives at once, which is what you want.

The one thing the floor doesn’t touch

Run npm ci against a lockfile that already contains vitest 5.0.5, with the seven-day floor on, and it installs 5.0.5 without complaint. A floor filters resolution, and a lockfile has already done the resolving. That’s the right behaviour for reproducible builds, but it means the floor guards new resolutions only. An agent that edits package.json and regenerates the lockfile goes through the floor. A version someone committed last week doesn’t.

The corollary is that a range in package.json can strand you. After npm install vitest@5.0.5 wrote ^5.0.5 into the manifest, npm update vitest with the floor on failed with the same ETARGET error, because nothing in ^5.0.5 is old enough. If an agent tidies a manifest by hand, the floor will surface it.

What goes wrong

The floor delays fixes too. The npm docs say min-release-age also blocks npm audit fix from installing a patch that is too recent: it keeps the vulnerable version, warns and exits non-zero. For a critical advisory you need a way through, and the cleanest is a human running one command with the flag set to 0, outside the agent’s session. Dependabot’s security updates already take that route.

The agent can also remove its own guardrail. Nothing stops it from passing --min-release-age=0 or editing .npmrc. A permission deny rule on commands that mention min-release-age, plus a deny on edits to .npmrc, covers the lazy cases. The permissions docs are clear that Bash deny rules match command text and aren’t a security boundary, so treat this as a speed bump with a review of the PR diff behind it.

Older npm versions ignore the option. Running npm config get min-release-age on npm 10.9.4 prints undefined, and passing the flag to npm 11.6 and 11.8 produced an “Unknown cli config” warning rather than an error. A floor in .npmrc that your old CI image silently ignores is worse than no floor, because everyone assumes it’s working. Check npm config get min-release-age on every machine that matters. It should print the number you set.

Finally, a cooldown does nothing about a package that was malicious from its first version and has been on the registry for a year. That’s a different problem, and the one a typosquat or an invented package name creates when an agent guesses at a dependency.

The enterprise version

On 20 October GitHub put a package firewall into public preview. It sits between your endpoints and the npm registry, and the changelog names CI hosts and coding agents as the endpoints to point at it. It’s for GitHub Enterprise Cloud only, supports npm only, is free during the preview, and will be billed on metered usage at general availability.

The relevant policy is “minimum package age”, which blocks versions newer than a set number of days (1 to 365, default 1 day). The same policy page lists blocking known malware, blocking versions affected by advisories at a chosen severity (critical only by default), blocking attestation downgrades, and blocking newly introduced install scripts. Setup is two lines in a user-level .npmrc that point the registry and an auth token at the firewall, with the token supplied as GITHUB_TOKEN.

The advantage over a local .npmrc is that an administrator sets the policy and the agent can’t edit it away. The cost is that it’s one ecosystem, one plan and a preview. For everyone else, the repo-level settings above are what exists today.

First 15 minutes

  1. Run npm --version and npm config get min-release-age. If the second prints undefined, upgrade npm before anything else.
  2. Add min-release-age=7 to the project .npmrc, and an exclude line for your own scope.
  3. Run npm install <something-you-use> --dry-run and compare it with the same command using --min-release-age=0. If the two differ, the floor is working.
  4. Add the Dependencies note to CLAUDE.md or AGENTS.md, including the ETARGET warning.
  5. Add the cooldown block to dependabot.yml.