Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8215c75
Start fix for #935, #938, #939
claude Oct 6, 2026
918a9ce
Stop VEX attesting beside an unpatched lock copy
claude Oct 6, 2026
7eda8d8
Route Gradle digests through utils::digest
claude Oct 5, 2026
980b7b6
Drop quadratic dedup of same-lock copies
claude Oct 6, 2026
3869e7e
Merge main into agent/fix-same-lock-unpatched-copy-vex
claude Oct 7, 2026
4ed7325
Merge remote-tracking branch 'origin/main' into prfix/940
mikolalysenko Oct 7, 2026
176327d
Stop vex attesting a hosted pin over a stale yarn PnP install (#519)
mikolalysenko Oct 7, 2026
869c81f
Merge remote-tracking branch 'origin/main' into prfix/940
mikolalysenko Oct 7, 2026
a0f253a
Contest a pnpm ref its lock also bundles (audit B04)
mikolalysenko Oct 7, 2026
5e5d88d
Read deno.lock as a contesting lock in VEX discovery (#406)
mikolalysenko Oct 7, 2026
7fb32e8
Document the PnP, pnpm bundled and deno.lock VEX rules
mikolalysenko Oct 7, 2026
acbac79
Merge branch 'main' into agent/fix-same-lock-unpatched-copy-vex
mikolalysenko Oct 7, 2026
b83da9c
Merge remote-tracking branch 'origin/agent/fix-same-lock-unpatched-co…
mikolalysenko Oct 7, 2026
9ac96fc
Keep pnpm bundled and deno.lock refs live; omit them only from vex
mikolalysenko Oct 7, 2026
98d9b0b
Contest a yarn berry ref beside a registry locator of the same version
mikolalysenko Oct 7, 2026
e9a33b8
Document unattested pnpm bundled and deno.lock refs in CLI_CONTRACT
mikolalysenko Oct 7, 2026
aeddea4
Merge origin/main into arch-fix/vex-false-attest
mikolalysenko Oct 7, 2026
314038b
Merge remote-tracking branch 'origin/main' into prfix2/1033
mikolalysenko Oct 7, 2026
01f9bf0
Merge remote-tracking branch 'origin/main' into arch-fix/vex-false-at…
mikolalysenko Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,13 +373,14 @@ Discovery is read-only, never touches the network, and never fails the run: a ma
| maven | `pom.xml` (+ `.mvn/maven.config`, `.mvn/checksums/checksums.sha256`) | a dependency version `<base>-socket.<hex8>` matching exactly ONE `socket-patch-<uuid>` repository on the patch host | `socket-patch-vendor-<uuid>` repository + exactly one jar under `.socket/vendor/maven/<uuid>/` with a matching `.sha1` | Trusted Checksums line when enabled; not required (the suffixed version is the pin) |
| gradle (v5.0) | `.socket/gradle/hosted-index.tsv`, the owned hosted script, and every settings script and lock file the script graph reaches | an index row whose repository URL is on the patch host and names the row's uuid, live only while the owned script is intact, every build's settings file applies it with the current index digest, every lock entry of the GA is the suffixed version, no `settings-gradle.lockfile` names the GA and no build script sets a custom `lockFile` (otherwise `patched_ref_invalid`) | none here: a vendored Gradle entry is gated by its ledger entry's wiring check | the suffixed copies installed in the Gradle cache; required (never the lock basis), because the script lets a higher upstream version resolve |
| nuget | the first of `nuget.config` / `NuGet.config` / `NuGet.Config`, + `packages.lock.json` | source `socket-patch-<uuid>` + its exclusive exact-id `<packageSourceMapping>`; version from `packages.lock.json` | the same mapping onto `.socket/vendor/nuget/<uuid>`; version from the lock, else the feed's single nupkg | `contentHash`, required |
| deno | none | — (no hosted mode) | — (no vendored backend) | — |
| deno | `deno.lock`'s npm section, only as evidence against an npm-family pin of a `name@version` it also locks, which `vex` then omits (see **Unattested references**, #406) | — (no hosted mode) | — (no vendored backend) | — |

Recognition rules that hold for every ecosystem:

* **Patch hosts.** A hosted reference counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin, with no userinfo. The uuid is the URL's LAST canonical-uuid path segment, because grant tokens may themselves be uuid-shaped. The Go module prefix is fixed. `socket-patch-<uuid>` registry / repository / source names count only through a pin. For a URL on any other host, see **Patch hosts** above.
* **Pins, not definitions.** A registry, index or source *definition* alone (cargo `[registries]`, nuget `<add>`, pom `<repository>`, uv index tables, `.npmrc`) never makes a reference, because it survives a reverted pin. Sections the package manager ignores are not read: npm's v2 `dependencies` mirror, a `.cargo/config.toml` shadowed by `.cargo/config`. A Socket pin inside a maven `<profile>` is diagnosed, never a reference.
* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm or Bun lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install`) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy.
* **Contested locks.** When one lock wires a package to a patch and another lock resolves the same `name@version` from a non-Socket source, the build's bytes depend on which package manager runs. The reference is then dropped with a `patched_ref_unattributable` diagnostic naming both files. This applies across npm / pnpm / yarn / bun and across uv / pylock / poetry / pdm / Pipfile.lock / requirements. PEP 723 script locks neither contest nor are contested. A **bundled** npm copy (`inBundle: true`, or v1 `bundled: true`) of the same `name@version` contests the reference too, in the same lock, in the other npm lock of a shrinkwrap/package-lock pair, or in any other lock. npm unpacks it from the parent package's tarball, so no rewire reaches it and it stays unpatched. Bun and vlt unpack bundled copies the same way (#469, #471). pnpm unpacks bundled copies too, but its lock cannot tie one to a reference (see **Unattested references** below). For Bun, that is a `bun.lock` entry whose meta is `{ "bundled": true }`, or a `bun.lockb` record that a dependency edge with the `bundled` behavior bit reaches, including one Bun shares with a regular install. Such an entry is never a reference, and it contests the reference the same way. For vlt, the lock records no node for a bundled copy, so the copy is found in the installed store: a real package directory inside a store package's own `node_modules`. Hosted and vendored scans skip these copies with `redirect_bun_bundled_instance_skipped` / `redirect_vlt_bundled_instance_skipped` / `vendor_bundled_instance_skipped`. When a bundled copy is the only instance, vendoring refuses with `vendor_lock_entry_not_rewritable`. Another entry of the **same** npm, Bun or yarn lock that resolves the wired `name@version` from a non-Socket source (for example a workspace member added after the rewire, then `npm install` / `bun install` / `yarn install`; for yarn berry, a registry locator such as `left-pad@npm:1.3.0` beside the hosted `…::__archiveUrl=` one) contests the reference too (#588). The package manager installs both entries, and that copy stays unpatched. Re-running `scan` / `vendor` rewires every copy.
* **Unattested references.** Some evidence shows a build may run a copy no wiring reaches, but cannot be tied to the reference's exact `name@version` or cannot say the build runs it. The reference then stays a reference: `list`, `rollback` and `remove` find it, and the ledgers' liveness gates (`vendor --check`, `scan`) keep treating the wiring as live, because re-running `scan` / `vendor` could never clear the evidence. Only `vex` omits it, as a run warning and as the `failed[].reason`. Two cases besides Gradle's (`vex_gradle_lock_above_base`): **pnpm bundled copies** (`vex_pnpm_bundled_copy`): a `packages:` entry's `bundledDependencies:` names the copies pnpm unpacks from that package's own tarball, but pnpm never locks them, so the bundled version is not in the lock. A pnpm reference whose package name a `bundledDependencies` list in the same lock names is omitted whatever its version (a missed attestation when the bundled copy is another version, never a false one), and `bundledDependencies: true` (or a value that cannot be read) omits every reference of that lock. It reaches no other lock. **deno.lock** (`vex_deno_lock_copy`): `deno install` installs a `package.json` project's npm dependencies from `deno.lock` and never reads `package-lock.json` / `pnpm-lock.yaml` / `yarn.lock`, so an npm-family reference whose `name@version` the `deno.lock` npm section also locks is omitted (#406). Whether Deno or another package manager populates `node_modules` (`nodeModulesDir: "manual"` allows either) is not in the files, so this holds whatever `nodeModulesDir` says.
* **Lockless pins.** With no lock to name a version, a `Cargo.toml` pin (every declaration on `socket-patch-<uuid>`, that registry defined on the patch host for the same uuid) or an exclusive nuget exact-id mapping is never a reference on its own, so v5.0 does not attest it (nor does `list` show it, or `rollback` / `remove` restore it — restore those files from version control). Only a pre-v5 redirect-ledger record naming a version the pin admits keeps it live. The same holds for a gem wired only in the `Gemfile` (the pre-bundler-2.6 mixed state, lock not converged).

**Record resolution.** A candidate's record must carry the patch uuid the lockfile actually **wires**. It is taken from the first source that has one: the manifest (matched qualifier-insensitively), the hosted records above (this run's, then a pre-v5 ledger's), then the vendor ledger's embedded records. If none has it and the run is online, `vex` fetches the patch view by uuid from the patch API — for a v5 hosted checkout this is the normal path. The fetch uses `get`'s API client: the public proxy when no token is configured, and a one-shot 401/403 fallback to the proxy (free patches only). At most 10 fetches run concurrently. Fetched records stay in memory: `vex` never writes the manifest. A candidate still has no record under `--offline`, after a transport error or a 404, or when the patch is refused (paid without an entitled token); it is then omitted as `record_unavailable`, and the run is not aborted. A record whose uuid or package disagrees with the wiring is omitted as `record_mismatch`. The informational `socket-patch.vendor.json` marker is never a record source. When the lockfile wires a package to patch U, a manifest or ledger record for that package under another uuid is superseded, and a human-mode `Note:` says so.
Expand All @@ -389,7 +390,7 @@ Recognition rules that hold for every ecosystem:
| Wiring | Evidence (verify mode) | Marker |
|---|---|---|
| Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` |
| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. | `(redirected)` |
| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. The same goes for npm purls when an installed pnpm tree records its virtual store outside the project (`enableGlobalVirtualStore`, or a `virtualStoreDir` that climbs out): transitive deps there are invisible to the crawler. A pnpm `modulesDir` inside the project is crawled. A yarn Plug'n'Play project (`.pnp.cjs` / `.pnp.js`) has no tree to crawl: an npm purl there attests from the lock's pin only when the PnP loader itself resolves it through the patch (berry names the hosted url, yarn 1 the resolved url's `#<sha1>` in its cache folder). A loader written before the lock was rewired runs the registry copy, so the purl stays omitted until `yarn install` rewrites it (#519). | `(redirected)` |
| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`; pnpm, vlt, Bun and Deno stores add peer-variant copies and copies bundled inside other packages) must hash to the patched bytes, as `apply` patches every copy (Maven: every copy a build consumes, Gradle hash dirs included — see [Gradle builds](#gradle-builds-v50)). One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none |

**Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates:
Expand Down Expand Up @@ -2150,6 +2151,8 @@ match it, withholds the statement:
| `vex_gradle_unpatched_copy` | run warning | A copy a build may load does not carry the patch; no statement until every copy does. |
| `gradle_unpatched_copy` | `failed[].reason` | The purl the warning above withheld. |
| `vex_gradle_lock_above_base` | run warning and `failed[].reason` | A hosted pin is wired, but a lock file records a release above its base, which that build resolves instead of the patch; no statement until it is re-locked or the patch is rolled back. |
| `vex_pnpm_bundled_copy` | run warning and `failed[].reason` | An npm pin is wired, but its pnpm lock names a package whose `bundledDependencies` may ship an unpatched copy of it (the lock does not record that copy's version); no statement while that package bundles it. `vendor --check` and `scan` still treat the wiring as live. |
| `vex_deno_lock_copy` | run warning and `failed[].reason` | An npm pin is wired, but `deno.lock` locks the same `name@version`, which `deno install` installs without the wiring; no statement while it does. `vendor --check` and `scan` still treat the wiring as live. |
| `vex_gradle_derived_cache_unchecked` | run warning | The derived-cache walk was cut short (a very large transforms cache); the statement is still emitted, the copies the walk did reach were checked. |

A vendored Gradle entry attests only while its wiring is live (apply line, index rows,
Expand Down
21 changes: 20 additions & 1 deletion crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,26 @@ async fn generate_vex(
// unpatched transitive dep (#696).
let npm_store_hidden =
socket_patch_core::crawlers::npm_crawler::pnpm_store_outside_project(&common.cwd);
let hidden = |purl: &str| npm_store_hidden && purl.starts_with("pkg:npm/");
//
// Nor can it look inside a yarn Plug'n'Play install: the packages
// are zips the loader resolves, so an npm purl not found may still
// run from the cache. The lock's pin is evidence only when the
// loader itself resolves the package through that patch (a fresh
// install of the hosted lock); a loader written before the lock was
// rewired runs the registry copy (#519).
let pnp_loader = socket_patch_core::crawlers::YarnPnpLoader::detect(&common.cwd);
let pnp_unconsumed = |purl: &str| {
pnp_loader.as_ref().is_some_and(|loader| {
!plan.hosted.get(purl).is_some_and(|wiring| {
loader.resolves_patch(
&wiring.uuid,
wiring.refs.iter().filter_map(|r| r.url.as_deref()),
)
})
})
};
let hidden =
|purl: &str| purl.starts_with("pkg:npm/") && (npm_store_hidden || pnp_unconsumed(purl));
let mut lockfile_attested = Vec::new();
outcome.failed.retain(|f| {
let excused = f.reason == "package_not_found"
Expand Down
Loading
Loading