Carry install locations to the wire so an import can be tied to an instance - #181
Merged
Merged
Conversation
…stance
The same package is routinely installed more than once at different versions — a workspace pinning an
old copy under `apps/api/node_modules`, a transitive dependency getting a nested install. The
manifest knew where each one lived: `PackageEntry.path` was parsed and populated. Then
`buildWirePayload` projected every entry to `{name, version}` and the locations were dropped at the
wire boundary, unread by anything in the package.
So this repo:
node_modules/lodash 4.17.21
apps/api/node_modules/lodash 4.17.11
sent two bare pairs. An advisory matching only the older one cannot then be resolved against the
app's code: the consumer either warns on a copy nothing reaches, or pins a rule to a route that runs
the safe one — a rule that never fires while reporting as protection.
Node resolves an import by walking up from the importing file, so the map's import sites and these
paths together answer "which instance does this import reach". Neither half answers it alone, which
is why the paths have to survive the projection.
`WirePackage.paths` carries them, sorted. The dedupe by name+version stays, but the duplicate entries
it dropped were the only record of the OTHER locations — one copy at the root and one pinned inside a
workspace is exactly the case that needs distinguishing — so their paths are collected onto the entry
that survives instead of discarded with them. Sorted because scan order follows a lockfile's key
order or a filesystem walk, and an unstable list looks to the server like a changed app on every
rebuild.
`WirePayload.installPathsComplete` is what separates "not installed there" from "we were not told". A
yarn.lock is flat — hoisting is decided at install time and the file does not record it — so a
missing `paths` from that source is not an answer. One entry without a location forfeits the claim for
the whole payload, since a consumer reading a short list cannot tell which entry was the incomplete
one. An empty package list does not claim completeness either: vacuous truth is the wrong answer when
the scan found nothing at all.
Two sources had the information and threw it away:
- **npm v1 lockfiles** have no path keys, but their NESTING is the layout — a nested `dependencies`
map is literally the `node_modules` directory inside its parent. Until now the same repo produced a
correlatable payload or an uncorrelatable one depending only on which npm wrote the lockfile, and
nothing in the result showed which had happened.
- **the `node_modules` walk** knew the directory it read each manifest from. It now reports it,
relative to the project root. For a package reached through a symlink that is the LINK location,
not the store target: a resolver walks up looking for `node_modules/<name>`, so the link is the path
an import actually travels, while the target only says where the bytes live.
`installLocation` takes the platform path module as a parameter. Nothing could otherwise prove the
POSIX normalization: CI runs on ubuntu only and `path.sep` is already `/` on every machine this is
developed on, so on Windows `node_modules\lodash` would have matched nothing server-side and the
correlation would have failed silently on exactly one platform. It has to be the whole module rather
than the separator alone — POSIX `path.relative` cannot parse `C:\app` either.
The manifest checksum is unchanged: it already projects to `{name, version}` before hashing, and a
test pins that, because if added fields moved the fingerprint every already-reported build would read
as changed the moment this shipped.
Every guard mutation-checked individually; each is distinguished by a test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Well-scoped feature with comprehensive opt-in install-paths implementation. 🎯 Quality: 100% Elite · 📦 Size: Large — consider splitting if possible 📈 This month: Your 138th PR — above team average · Averaging Excellent |
The privacy statements were written when the payload really was only names and versions, and they say so
in absolute terms:
AGENT-INSTALL.md "`scan` transmits nothing but package names + versions — never source code,
env var values, file paths, or git history."
README.md "That's the entire payload. No source code, no environment variable values,
no file paths — just the package names and versions from your lockfile."
Sending `paths` makes all three false. That is not a documentation nicety here: agents `npm pack` the
tarball and audit it against the docs, and an overbroad privacy claim is a recorded refusal driver —
field-test mode 6, where a contradiction between the docs and `dist/` was quoted back as
misrepresentation and the install refused. Shipping the payload change without this would have earned
that refusal, and the refusal would have been correct.
So the docs now say what is actually sent, and separate it from what still is not: install locations
are repo-relative paths built from `node_modules` segments plus a workspace directory name, read from
the lockfile's own keys or the `node_modules` walk — never from the source tree. Source file paths are
still never sent by `scan`; `map --upload` remains the only command that sends anything derived from
source, and only behind its flag.
The reason is stated where the claim is, because "we send your dependency layout" with no rationale
invites exactly the refusal this is avoiding: two copies of one package at different versions is the
normal case, and without the locations an advisory affecting one of them cannot be matched to the copy
the app loads — so every installed version has to be treated as used, producing warnings about
uncalled code and rules pinned to routes running the safe copy.
The README payload example now shows the real shape, including `installPathsComplete`, and states what
`false` means: a missing `paths` is then "not recorded", not "not installed there".
`install.txt` carries the same absolute claim and is agent-facing, but it lives outside this
repository, so it is not in this commit — it has been corrected identically in the working tree that
holds it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sibling change got exactly this wrong: a path relativized against the wrong root escaped to `../../home/runner/work/connect/connect/vendor`, and the document carrying it is uploaded. Every local assertion about that value passed, because an escaped path is still a string — the failure is silent by construction. This payload is the other place paths now leave the machine, so the same invariant is pinned here, over each source that can produce a location: nothing absolute, no `..` segment, and the fixture's own path appears nowhere in the payload. Locations taken from lockfile keys cannot escape, but they are covered anyway — the point is that no future source can be added without meeting the invariant. Both escape shapes are caught: returning the absolute directory, and relativizing against a wrong root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…file
Two problems with the previous shape, both of which bound an advisory to something the scan did not
actually know.
**A v1 lockfile's nesting is the dependency GRAPH, not the installed tree.** npm hoists and dedupes, so
a package nested under `debug` in the lockfile is usually installed at the top level; the nesting only
survives where a version conflict forced it. Reading it as the layout invented a path that is wrong in
the common case — and then claimed `installPathsComplete: true` over it.
An invented location is worse than none. `paths` exists so an advisory can be bound to the instance the
app's import resolves to, and a confident wrong location binds it to the wrong one: the same failure the
field exists to prevent, reached from the other side. v1 now emits nothing, the payload reports
incomplete, and a consumer reads "not recorded" instead of a fabrication. A v1 project's locations have
to come from the `node_modules` walk, which reads the real tree.
**Locations are now opt-in — `scan --install-paths`, off by default.** They widen what leaves the
machine, and the package's standing promise is that `scan` sends names and versions and no paths of any
kind. An upload that widens that should be an explicit choice, not a consequence of upgrading a
dependency. The programmatic `scanAndReport({ installPaths })` matches, so a library caller does not get
a widened payload by upgrading either.
With the flag off, every entry counts as having no location, so `installPathsComplete` is already false
— which is the safe direction, and why there is no separate check for it. Gating the locations does not
gate the vulnerability matching that already worked: names and versions are unchanged, and the checksum
is identical either way, so turning the flag on does not read server-side as a changed build.
The docs go back to the strong default claim and describe the flag as the one exception, keeping the
part that still holds unconditionally: no path to a file the author wrote is sent by either form of
`scan`. `install.txt` likewise, in its own working tree.
`--install-paths` rather than `--paths`: `scan` already deals in file paths it must never send, and the
field it controls is `installPathsComplete`.
Every guard mutation-checked. One was removed rather than kept — an explicit `withPaths &&` in the
completeness expression that no test could distinguish from its absence, because the value it guarded
was already false by the path that reaches it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
/review |
daniloradovic
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
The same package is routinely installed more than once at different versions — a workspace pinning an old copy, a transitive dependency getting a nested install. The manifest knew where each one lived (
PackageEntry.pathwas parsed and populated). ThenbuildWirePayloadprojected every entry to{name, version}and the locations were dropped at the wire boundary, unread by anything in the package.Measured on a workspace fixture:
An advisory matching only the older one cannot be resolved against the app's code. The consumer either warns on a copy nothing reaches, or pins a rule to a route that runs the safe one — a rule that never fires while reporting as protection.
Node resolves an import by walking up from the importing file, so the map's import sites and these paths together answer "which instance does this import reach". Neither half answers it alone.
What changed
WirePackage.paths— the locations, sorted. The dedupe by name+version stays, but the duplicate entries it dropped were the only record of the other locations (one copy at the root, one pinned inside a workspace is exactly the case that needs distinguishing), so their paths are collected onto the surviving entry instead of discarded with them. Sorted because scan order follows a lockfile's key order or a filesystem walk, and an unstable list looks to the server like a changed app on every rebuild.WirePayload.installPathsComplete— separates "not installed there" from "we were not told". Ayarn.lockis flat (hoisting is decided at install time and the file does not record it), so a missingpathsfrom that source is not an answer. One entry without a location forfeits the claim for the whole payload, since a consumer reading a short list cannot tell which entry was incomplete. An empty package list does not claim completeness either — vacuous truth is the wrong answer when the scan found nothing.Two sources had the information and threw it away:
dependenciesmap is literally thenode_modulesdirectory inside its parent. Until now the same repo produced a correlatable payload or an uncorrelatable one depending only on which npm wrote the lockfile, and nothing in the result showed which had happened.node_moduleswalk knew the directory it read each manifest from. It now reports it. For a package reached through a symlink that is the link location, not the store target: a resolver walks up looking fornode_modules/<name>, so the link is the path an import actually travels, while the target only says where the bytes live.Windows
installLocationtakes the platform path module as a parameter. Nothing could otherwise prove the POSIX normalization — CI runs on ubuntu only andpath.sepis already/on every machine this is developed on, so on Windowsnode_modules\lodashwould have matched nothing server-side and the correlation would have failed silently on exactly one platform. It has to be the whole module rather than the separator alone: POSIXpath.relativecannot parseC:\appeither (the first version of this test proved that by returning../C:/app/...).Compatibility
computeManifestChecksumalready projects to{name, version}before hashing; a test pins it. If added fields moved the fingerprint, every already-reported build would read as changed the moment this shipped.StoreManifestvalidatespackages.*.nameandpackages.*.version; Laravel ignores unvalidated keys, so the added fields are accepted and simply unused until SaaS reads them.Tests
10 tests in
tests/install-paths.test.ts. Every guard mutation-checked individually:One existing expectation changed:
tests/node_modules_walk.test.ts"reads a package reached through a symlink" now expectspath: 'node_modules/real', with a comment on why the link and not the target.Full suite: 1594 passed, 6 skipped; typecheck and template typecheck clean.
Follow-up (not in this PR)
pnpm-lock.yamlandyarn.lockstill supply no locations, so a pnpm/yarn project reportsinstallPathsComplete: false. pnpm'simporterssection does encode per-workspace dependencies and could be derived from; yarn classic genuinely cannot. Consuming any of this — resolving an import site to an instance — is SaaS work.🤖 Generated with Claude Code