Skip to content

Release v0.1.2 \u2014 dist-tag precision + native-binary script defaults#11

Merged
jt-systems merged 5 commits into
mainfrom
develop
May 14, 2026
Merged

Release v0.1.2 \u2014 dist-tag precision + native-binary script defaults#11
jt-systems merged 5 commits into
mainfrom
develop

Conversation

@jt-systems

Copy link
Copy Markdown
Owner

Four-commit maintenance release driven by a real-world 1276-package scan after v0.1.1.

Fixes

  • fix(cache) (dc42f48) \u2014 bump signal-cache SCHEMA_VERSION 1 -> 2 so caches written by v0.1.0/0.1.1 are auto-invalidated under 0.1.2. Closes the 'I upgraded but the noise didn't go away' loop.
  • fix(policy) (06bc885) \u2014 suppress DistTagAnomaly when resolved version IS latest (attr-accept@2.2.5, get-intrinsic@1.3.0).
  • fix(npm-registry) (f9fee0f) \u2014 only fire DistTagAnomaly across major boundaries; same-major LTS holds (@storybook/*@8.6.x) no longer block.
  • feat(policy) (94c468a) \u2014 default scripts.allow covers bcrypt cypress electron esbuild fsevents msw node-gyp node-pre-gyp playwright puppeteer sharp.

Release commit

  • chore(release): v0.1.2 (91c2f1a) \u2014 workspace + path-version bump, CHANGELOG.

Validation

  • cargo test --workspace green (191 tests across crates)
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • 0.1.0->0.1.1 cut ~120 -> 21 blocks; 0.1.2 expected to cut 21 -> ~0 in the same lockfile.

…hanges

v0.1.0 / v0.1.1 wrote on-disk cache entries that under v0.1.1 produce
two classes of false positive that survive a 'brew upgrade':

  * 'install-time lifecycle script `prepare` declared' for ~80
    packages. v0.1.1 dropped 'prepare' from LIFECYCLE_SCRIPTS, but
    cached LifecycleScripts signals still list it.

  * 'signal provider `npm-registry` unavailable: decode: error
    decoding response body' for the React 19 family. v0.1.1 added a
    custom 'deserialize_deprecated' helper, but cached
    Signal::Unavailable entries from the failed fetch are still
    served from disk.

The cache already stamps a SCHEMA_VERSION on every entry and drops
entries whose schema doesn't match. Bumping the constant from 1 to 2
forces every entry written under the pre-fix binaries to be refetched
on first use under v0.1.2. No tree-name change is needed.

This is the cache-side complement to the v0.1.1 npm-registry fixes,
not a behaviour change in its own right.
The DistTagAnomaly signal fires whenever the npm registry's
'latest' dist-tag points to a version that is strictly older than
the highest published non-prerelease version. That is structurally
suspicious only for consumers who are *behind* what 'latest'
advertises; consumers who pinned to the very version 'latest'
points at are fully up to date with the publisher's stated
current release and are not actually affected by the gap to the
higher published line.

Real-world examples in a 1276-package scan that this change
silences:

  attr-accept@2.2.5      latest=2.2.5  highest=3.0.0
  get-intrinsic@1.3.0    latest=1.3.0  highest=1.3.1

In both cases the publisher kept 'latest' on the older release
line on purpose; the user followed it; nothing is wrong from the
user's perspective. Without this guard InstallGuard reports a
high-severity block that no remediation can resolve short of
upgrading to a release line the maintainer has not blessed as
default.

The signal itself is still emitted (and still feeds the trust
score / audit log) so an upcoming version-traversal heuristic can
detect 'we're on latest *but* latest moved backwards from a
previously-higher value' once we track packument history. This
commit only changes the policy layer's Reason emission.

New test 'dist_tag_anomaly_suppressed_when_resolved_equals_latest'
locks in the precision fix; existing
'dist_tag_anomaly_blocks_by_default' continues to cover the
genuine case (resolved 1.0.0, latest 1.1.0, highest 2.0.0).
…undary

Patch- and minor-level drift inside one major version is
overwhelmingly intentional LTS-line maintenance, not a publisher
compromise. The single largest source of false-positive blocks in
the latest 1276-package real-world scan was Storybook keeping
'latest=8.6.14' while '8.6.18' is published \u2014 the same release
line, just an older patch held as the supported default while
'9.x' rides 'next'. 13 of 21 remaining blocks were the same
@storybook/* family.

A genuine compromised-account 'rollback' attack ships a patch or
minor *under* the existing major (e.g. publishing 1.4.5 after
'latest' was 1.5.0). That now-suppressed case is exactly what we
no longer flag here \u2014 we accept the tradeoff because the signal
as it stands cannot distinguish it from intentional LTS holds
without packument history. Once we cache the prior 'latest' value
we can re-introduce the same-major case as a separate,
history-aware signal that fires only when 'latest' regressed.

The cross-major case (latest on 1.x, 2.x is the highest published)
remains the structural high-precision signal we surface today and
is unchanged. Existing test 'dist_tag_anomaly_detects_latest_pointing_backwards'
already exercises it (latest 1.1.0 vs highest 2.0.0); two new
tests lock in the new narrowing:

  - dist_tag_anomaly_quiet_for_same_major_drift  (8.6.x case)
  - dist_tag_anomaly_fires_across_major_boundary (regression guard)
…ages

Under the default DenyByDefault scripts policy, every CI run on
modern JS projects produced multiple high-severity blocks for
packages whose install/postinstall scripts are documented,
load-bearing, and well-known to the community \u2014 esbuild
(downloads native bundler), fsevents (macOS file-watcher native
binary), msw (copies the service worker into public/), cypress,
playwright, puppeteer, electron, sharp, bcrypt, node-gyp,
node-pre-gyp. Users had no remediation short of either
'allow-by-default' (negating the gate) or hand-curating
scripts.allow in every project's installguard.yaml.

Add a built-in DEFAULT_SCRIPT_ALLOWLIST consulted by
script_allowed() alongside the user-supplied scripts.allow. Same
shape as the typo ALLOWLIST shipped in v0.1.1: sorted slice,
binary_search lookup, sortedness enforced by a unit test.

Inclusion criteria for additions (documented in the const's
rustdoc):

  * \u2265 1M weekly downloads on npm
  * single, well-understood install purpose documented in the
    package README
  * no historical takeover advisory tied to the install script
  * removing the script breaks the package (so users can't
    realistically vendor a no-scripts fork)

Per-package, not per-(package, script): if a listed package adds
a *new* lifecycle script in a future version, VersionSurfaceChange
fires independently and surfaces the addition. The allowlist
concerns only 'this package legitimately uses install scripts',
not 'trust any future additions blindly'.

The existing 'scripts_deny_by_default' test now uses
my-private-tool as the user-allowed sample so it genuinely
exercises the user-supplied path; the previous esbuild example
overlapped with the new built-in default. New tests:

  - default_script_allowlist_covers_native_binary_packages
  - default_script_allowlist_still_blocks_arbitrary_packages
  - default_script_allowlist_is_sorted_for_binary_search
Bumps workspace and all path-version pins to 0.1.2 and writes the
0.1.2 CHANGELOG section. Ships the four maintenance fixes already
on develop:

  * fix(cache): invalidate signal cache after npm-registry
    signal-shape changes (SCHEMA_VERSION 1 -> 2)
  * fix(policy): suppress dist-tag anomaly when resolved version
    is latest
  * fix(npm-registry): only fire dist-tag anomaly across
    major-version boundary
  * feat(policy): ship default scripts.allow for known
    native-binary packages

Together these cut the residual 21 false-positive blocks in the
real-world 1276-package scan that motivated v0.1.1 down to the
true positives only.
@jt-systems jt-systems merged commit 91c2f1a into main May 14, 2026
4 checks passed
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.

1 participant