diff --git a/Cargo.lock b/Cargo.lock index 9527cc78a..25d1f4b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -2081,6 +2082,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 2f20a92cd..acbbe4672 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ repository = "https://github.com/SocketDev/socket-patch" [workspace.dependencies] socket-patch-core = { path = "crates/socket-patch-core", version = "=5.1.0" } -clap = { version = "=4.5.60", features = ["derive", "env"] } +clap = { version = "=4.5.60", features = ["derive", "env", "wrap_help"] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = { version = "=1.0.149", features = ["preserve_order", "raw_value"] } sha2 = "=0.10.9" diff --git a/README.md b/README.md index e7fc7f4b9..6b2073014 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,7 @@ without waiting for an upstream release or upgrading the dependency. Socket Patch updates dependency files to use patched packages, which you install with your normal package manager. -> This branch documents the v5 prerelease. Installation commands below select the -> latest published release, which may have different behavior. To try this branch, -> [build from source](docs/development.md#build). Existing users should read the -> [v5 migration guide](docs/migrating-to-v5.md). +> Upgrading from v4? Read the [v5 migration guide](docs/migrating-to-v5.md). ## Installation @@ -102,10 +99,33 @@ control. See [usage and recovery](docs/usage.md). Use `socket-patch --help` for options and [`socket.yml`](docs/configuration.md#repository-patch-policy) for a shared rollout policy. +## Telemetry + +Socket Patch sends usage telemetry by default: each command reports its outcome +to Socket as a small JSON event. An event holds the event name (for example +`patch_applied` or `patch_scan_failed`), the subcommand, the CLI version, the +operating system and CPU architecture, a timestamp, a random ID generated for +that one run, and a few counters and flags for the command (such as patches +applied, packages scanned, ecosystems found, the patch mode, `dry_run`). `get` +adds the patch UUID. A failure adds the error message, with URL credentials +redacted and your home directory replaced by `~`. Apart from what an error +message quotes, events hold no package names, file paths, file contents, or +dependency lists. + +Events go to `api.socket.dev` with your API token when a token and organization +are configured, otherwise anonymously to `patches-api.socket.dev`. They are sent +in the background: a command never waits for telemetry before it prints, a +failed send changes nothing, and an unreachable endpoint adds at most 300 ms to +the exit. + +To turn telemetry off, pass `--no-telemetry` or set `SOCKET_TELEMETRY_DISABLED=1` +or `DO_NOT_TRACK=1`. `--offline` / `SOCKET_OFFLINE=1` sends nothing either. See +[configuration](docs/configuration.md#telemetry) for every event, field, and switch. + ## Documentation - [Usage](docs/usage.md): targeting, CI, vendoring, agent mode, VEX, and recovery. -- [Configuration](docs/configuration.md): authentication, environment, and rollout policy. +- [Configuration](docs/configuration.md): authentication, environment, telemetry, and rollout policy. - [Ecosystem support](docs/ecosystems.md): package-manager formats and limitations. - [Migrating to v5](docs/migrating-to-v5.md): changed defaults and retired install hooks. - [CLI contract](crates/socket-patch-cli/CLI_CONTRACT.md): flags, JSON, diagnostics, and exit codes. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 259b5dbfd..1ef4c0ae5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -24,7 +24,7 @@ For task-oriented guidance, start with [usage](../../docs/usage.md), | `apply` | — | Agent mode: apply patches from the local manifest | | `rollback` | — | **Full-state rollback (v5.0, MAJOR)**: restore original files AND unwind vendored lockfile wiring / restore hosted pins to their upstream registry entries, remove the rolled-back entries from the manifest, and GC their blobs/archives; takes optional variadic positional `targets` (PURL \| UUID \| package name \| path glob). See [Rollback command contract](#rollback-command-contract-v50) | | `remove` | — | Restore and remove one patch across hosted, vendored, and agent state; requires positional `identifier`. | -| `repair` | — | Download missing agent blobs, redownload missing/corrupt vendored artifacts (never re-synthesizing a lost ledger), and clean up unused ones: a blob no manifest patch references (the afterHash and beforeHash blobs of every manifest patch are kept, so an offline `rollback` still has its originals; `scan --prune` keeps the same set) and every obsolete diff or package archive (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | +| `repair` | — | Download missing agent blobs and replace corrupt ones (every referenced blob on disk is re-hashed against its git-sha256 name), redownload missing/corrupt vendored artifacts (never re-synthesizing a lost ledger), and clean up unused ones: a blob no manifest patch references (the afterHash and beforeHash blobs of every manifest patch are kept, so an offline `rollback` still has its originals; `scan --prune` keeps the same set) and every obsolete diff or package archive (refuses with `lock_held` when a live process holds the lock; see "Lock lifecycle" below) | Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex` → `vendor`, with `list` to inspect), then the agent-mode (in-place patching) commands. @@ -32,7 +32,7 @@ Rows are in `--help` order (v5.0): the hosted/vendored workflow (`scan` → `vex **Removed in v4.0:** the `unlock` subcommand (a leftover lock from a crashed run never blocks acquisition — the OS releases a dead holder's advisory lock — so there is no stale-lock state to inspect or clear before a mutating command; `repair` briefly owned lock-file cleanup in v4.x, and since v5.0 every lock-taking command removes its own lock file on exit). -**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. An interrupted run (Ctrl-C, SIGTERM, SIGHUP; Ctrl-C, Ctrl-Break or console close on Windows) removes the file on its way out and still dies by that signal; only an uncatchable kill (SIGKILL, power loss) can leave `apply.lock`, and the next lock-taking command reclaims it in place and removes it. socket-patch never keeps a persistent lock file in the project. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, agent-mode `get` and `scan --mode agent`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it before its first wet write (the staged takeover reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way. A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. +**Lock lifecycle (v5.0).** `<.socket>/apply.lock` never outlives the command that took it: acquisition creates `.socket/` when it is missing, the guard's drop unlinks the file WHILE the lock is still held (so a waiter can never lock an orphaned inode), releases it, and then removes `.socket/` itself if that left the directory empty — a run that had nothing to persist leaves no `.socket/` behind, and there is nothing to `.gitignore`. An interrupted run (Ctrl-C, SIGTERM, SIGHUP; Ctrl-C, Ctrl-Break or console close on Windows) removes the file on its way out and still dies by that signal (on Unix it first kills, and waits for, a gem native-extension build it started, so the lock is never released over a running build); only an uncatchable kill (SIGKILL, power loss) can leave `apply.lock`, and the next lock-taking command reclaims it in place and removes it. A closed stdout or stderr pipe is not an interrupt: the command keeps running and its guard drops normally (see "Human output conventions"). socket-patch never keeps a persistent lock file in the project. The same holds for the transient staging entries commands create directly under `.socket/` (`.socket-stage-*`: `rollback --dry-run`'s blob stage, the overlay `apply` downloads missing blobs into, an atomic write's stage file): each is removed by the run that made it, and since a signal or a kill skips that, every lock acquisition other than a dry run's also removes the ones older than an hour. The age floor only matters on a filesystem whose locks do not exclude. The lock is taken by `apply`, `rollback`, `remove`, `repair`, `vendor`, agent-mode `get` and `scan --mode agent`/`--sync` (download → manifest write → nested apply is ONE lock window — the nested apply never re-acquires), and `scan`/`get` in vendored **and hosted** mode — hosted acquires it before its first wet write (the staged takeover reverts), never on `--dry-run` and never when the run would write nothing, so hosted previews and no-op runs create no `.socket/`. Dry runs of the other commands may still take the lock; it is residue-free either way, and a dry run never finishes an interrupted vendored commit while holding it (`vendor_commit_pending`, see **Vendored group commit**). A live holder is `lock_held` (exit 1); a directory or special file squatting on `.socket/` or on the lock path is a lock I/O error — `lock_io` (exit 1, `failed to open lock file at : …`; a read-only project root surfaces the same code at the acquire, before any ledger or manifest write) — never `lock_held`. **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `, also when root-position flags come first (`socket-patch --json `); a UUID after a subcommand name is that subcommand's operand. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive), the target grammar's. @@ -94,9 +94,9 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--manifest-path` | — | `SOCKET_MANIFEST_PATH` | `.socket/manifest.json` | path | Manifest location (resolved relative to `--cwd`). v5.0 (#745): it also selects the project whose stores every command reads and writes — the root the RESOLVED manifest path implies: its `.socket` parent's parent when the manifest sits in a `.socket/` directory (exactly `--cwd` for the default path), else `--cwd` — a manifest file elsewhere (`state/patches.json`, `/etc/socket/manifest.json`) relocates only the manifest and its lock/artifact directory, not the project (v5.0; previously the file's own directory). All sources always come from that SAME project: `list`, `apply` (incl. `--check`), `rollback`, `remove`, `repair`, `vex`, `vendor --check` and agent-mode `scan`/`get` load (and `rollback`/`remove`/`repair`/`scan --prune` revert and save) that project's vendor ledger and its `.socket/vendor/` artifacts, under the manifest's apply lock, never `--cwd`'s; `list`, `vex`, `rollback`, `remove` and `repair` likewise read (and `rollback`/`remove` unwind) that project's lockfile hosted pins and pre-v5 redirect ledger. Installed copies are still found from `--cwd` (or the global scope). The forms that rewire `--cwd`'s lockfiles — `scan`/`get --mode hosted\|vendored` and `vendor` other than `--check` — refuse a manifest in another project: usage error (exit 2), code `manifest_path_foreign_project`, checked before anything is read or locked. So does an embedded `--vex` on `apply` (not `--check`) and agent-mode or report-only `scan`: those runs patch `--cwd`'s installed copies, while the document would attest the manifest's project; standalone `vex` runs wholly in the manifest's project and is allowed. A manifest that resolves into `--cwd`'s own project (the default path, `./.socket/manifest.json`, or a `..` detour back into `--cwd`) is never refused. | | `--api-url` | — | `SOCKET_API_URL` | `https://api.socket.dev` | string | Authenticated API endpoint | | `--api-token` | — | `SOCKET_API_TOKEN` | (none) | string | Auth token (absence selects the public proxy) | -| `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. Resolved once per run from the token (`GET /v0/organizations`) when omitted; if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | +| `--org` | `-o` | `SOCKET_ORG_SLUG` | (auto-resolve) | string | Org slug. When omitted it is resolved from the token (`GET /v0/organizations`) once per run, the first time the run needs the patch API (see [API client construction](#api-client-construction)); if that fails, the whole run uses the public proxy anonymously (free patches only) and warns. | | `--proxy-url` | — | `SOCKET_PROXY_URL` | `https://patches-api.socket.dev` | string | Public proxy when no token | -| `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems | +| `--ecosystems` | `-e` | `SOCKET_ECOSYSTEMS` | (all) | CSV → `Vec` | Restrict to these ecosystems: `npm`, `pypi`, `cargo`, `gem`, `golang`, `maven`, `composer`, `nuget`, `deno`. Names are case-insensitive, and the names output prints (`python`, `ruby`, `go`, `php`) and everyday ones (`pip`, `rubygems`, `rust`, `crates`, `java`, `gradle`, `sbt`, `dotnet`, `packagist`, `jsr`, and `node`/`pnpm`/`yarn`/`bun`/`vlt` for `npm`) are accepted and normalized to the slug; `socket.yml` `patches.ecosystems` takes the same names (additive/MINOR). Anything else is a usage error listing the slugs | | `--vendor-source` | — | `SOCKET_VENDOR_SOURCE` | **`service`** | enum: `service` \| `auto` (alias) | How `vendor` acquires the installable artifact (see "Prebuilt vendor artifacts") | | `--maven-config` | — | — | (recorded choice, else `auto`) | enum: `auto` \| `none` | Maven reactor vendoring: write the repository tail (`auto`) or use only the fallback file repository (`none`). The choice persists in the vendor ledger. | | `--vendor-url` | — | `SOCKET_VENDOR_URL` | (active API/proxy base) | string | Base host for the vendoring-service package-reference request | @@ -113,14 +113,16 @@ Every subcommand accepts the same set of "global" flags via a single shared `Glo | `--yes` | `-y` | `SOCKET_YES` | `false` | bool | Skip prompts (`scan` never prompts) | | `--lock-timeout` | — | `SOCKET_LOCK_TIMEOUT` | (none) | seconds (u64) | How long to wait for `<.socket>/apply.lock`. Unset and `0` both mean a single non-blocking try; a positive value retries with a 100 ms backoff. Only meaningful on the lock-taking subcommands — `apply`, `rollback`, `repair`, `remove`, `vendor`, and `scan`/`get` whenever they write (agent-mode download + apply, vendored, hosted) | | `--debug` | — | `SOCKET_DEBUG` | `false` | bool | Verbose debug logs to stderr | -| `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable anonymous usage telemetry | +| `--no-telemetry` | — | `SOCKET_TELEMETRY_DISABLED` | `false` | bool | Disable usage telemetry (every switch: [Telemetry](#telemetry)) | | `--no-trust-lockfile-config` | — | `SOCKET_NO_TRUST_LOCKFILE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `trustLockfile: true` write to `pnpm-workspace.yaml` (see the pnpm trust-config note under the scan arguments) | | `--no-npm-allow-remote-config` | — | `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG` | `false` | bool | Opt out of hosted mode's automatic `allow-remote=all` write to the project `.npmrc` (see the npm allow-remote note under the scan arguments). Read by `scan --mode hosted` and `get --mode hosted`; other subcommands accept it silently | | `--no-vlt-install-cleanup` | — | `SOCKET_NO_VLT_INSTALL_CLEANUP` | `false` | bool | Opt out of hosted mode's warm-tree heal for vlt: stale installed copies (`node_modules/.vlt-lock.json` and the stale `node_modules/.vlt/` entries) are left in place after `vlt-lock.json` is repointed (`scan`/`get --mode hosted`) or restored (`rollback`/`remove`), and the `redirect_vlt_reinstall_required` advisory tells you to run `vlt ci` instead. Stale copies of optional dependencies are always left in place (see `redirect_vlt_reinstall_required`). Other subcommands accept it silently | +| `--no-gem-install-refresh` | — | `SOCKET_NO_GEM_INSTALL_REFRESH` | `false` | bool | Opt out of hosted mode's gem stale-install refresh: the gems Bundler already holds for the project (an installed gem under the project and its cached `.gem`, an archive in `vendor/cache`) are left as they are after `Gemfile.lock` is repointed (`scan`/`get --mode hosted`) or restored (`rollback`/`remove`), and the `redirect_gem_stale_install` / `upstream_gem_stale_install` warning names the files to remove by hand instead (see the "Gem stale-install guard" section). Other subcommands accept it silently | +| `--gem-refresh-shared` | — | `SOCKET_GEM_REFRESH_SHARED` | `false` | bool | Extend the gem stale-install refresh to gem homes OUTSIDE the project (the system gems, `~/.gem`, an rbenv or asdf home) and to project bundle or cache paths that resolve outside the project directory. Off by default: another project on the machine may pin the other build of the same gem version, and its `bundle pristine` then fails on the replaced archive's checksum (see the "Gem stale-install guard" section). Other subcommands accept it silently | **`--download-mode` removed (v5.0, MAJOR).** The global `--download-mode` flag and its `SOCKET_DOWNLOAD_MODE` binding are gone, with no alias: passing the flag is a usage error (exit 2) and the env var is ignored. Patch content is always fetched as per-file blobs (`.socket/blobs/`); `.socket/diffs/` archives are obsolete, never read, and removed by the cleanup sweeps. -`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. +`--offline` means the same thing on every command (v3.0): never contact the network, fail loudly when a required local source is missing. On `repair`, `--offline` and `--download-only` are mutually exclusive (exit 2), and a referenced blob that is missing or corrupt, which only a download can restore, fails the run: one `failed` event per blob (`offline_missing_sources` / `blob_corrupt`), exit 1, never "Repair complete." (the cleanup pass still runs). `scan` and `get` need remote data for their core function (patch discovery / patch fetch), so `--offline` refuses them up front — exit 1 with an error naming the offline gate (JSON: `status: "error"`), before any crawl, client build, or network contact. This covers `scan --mode vendored` too: offline vendored staging is `vendor --offline`'s job. The in-place apply paths (`apply`, agent-mode `get` / `scan`, and their nested apply calls) reject files matching neither `beforeHash` nor `afterHash` by @@ -252,7 +254,19 @@ the named package conservatively. Matching is conservative across copies and workspace members: one declaration protects every selected copy of that release. Unreadable/malformed metadata, dynamic patch directories, or a workspace glob the bounded reader cannot inspect protect the affected ecosystem with an explanatory -conflict diagnostic. Filesystem inspection never executes hooks or fetches patch +conflict diagnostic. A file that could not be read or parsed at all (a +`package.json` that is not valid JSON, a lock that is not valid YAML, a permission +error) is a broken file, not a conflict, and is reported as one: the +`user_patch_conflict` text reads `: not patched, because this project's own +patches for it could not be checked: (not valid JSON: ). Fix that +file and run again.`, and a scan whose every package was held back this way ends +`N packages were not scanned: this project's own patches could not be checked (see +the warnings). Fix the file they name and run again.` Neither names +`--allow-patch-conflicts`: switching the check off is not the remedy for a syntax +error (the flag still lifts the gate, as before). The code, the `skipped` events +and the exit code are unchanged. A scan held back by patches the project does +declare ends `N packages are protected by this project's own patches and were not +scanned (see the warnings). Pass --allow-patch-conflicts to scan them anyway.` Filesystem inspection never executes hooks or fetches patch URLs. Arbitrary scripts, external build pipelines, unrecorded edits to files the Socket patch does not touch, and global-install patch declarations cannot be identified reliably; rejecting unknown file drift remains the fallback. This is @@ -266,8 +280,8 @@ Beyond the globals above, each subcommand defines a small set of local arguments | Subcommand | Local arg | Env var | Purpose | |---|---|---|---| -| `apply` | `--force` / `-f` | — | Bypass beforeHash check | -| `apply` | `--check` | — | Read-only audit that every in-scope (`--ecosystems`) manifest patch is in place, for CI / GitHub-App auditing (v5.0: previously Go-only, which passed on any unpatched non-Go tree). Local Go patches: the committed `.socket/go-patches/` copies and `go.mod` `replace` directives match the manifest (`go_redirect_drift`). Every other patch: each installed copy hashes to the record's `afterHash` — the `vex` verifier over the `vex` copy lookup; the copies are narrowed by `apply`'s own rules: a release variant (a qualified purl such as `?artifact_id=` / `?platform=`) is judged only on the copies holding its distribution, matched against every variant of its base as `apply` matches them, and an installed copy that holds none of them is drift of the base purl (`no_matching_variant`, the copy `apply` fails with "no matching variant found"; a Gradle / Ivy cache dir is exempt, as in `apply`); for gem, once a bundle-store copy exists the `gem env` fallback-home copies are not judged (`apply` treats them as best-effort). Drift is a `failed` event per patch with `errorCode` `not_applied` (still unpatched), `hash_mismatch` (neither the original nor the patched bytes), `file_not_found` or `no_matching_variant`, status `partialFailure`, exit 1, and the human `Error: Patches are OUT OF SYNC:` report (printed even under `--silent`), which ends with the remedy ``Run `socket-patch apply ` to regenerate them.``: `` repeats the check's own `--cwd`, `--manifest-path`, `-g` / `--global-prefix` and `--ecosystems` (each shell-quoted when needed), so running it verbatim heals the tree that was checked (v5.0, #1219). In sync is exit 0: `Patches are in sync (N checked).` and, under `--json`, a `skipped` event per verified patch (`errorCode: already_patched`). A patch with no installed copy is skipped as `apply` skips it (`package_not_installed`; the human line adds `M not installed, skipped`). Vendor-owned patches are excluded (`vendor --check` audits them). Lock-free, fetch-free, offline-safe; it never writes. An unreadable manifest is drift (`manifest_unreadable`, exit 1) | +| `apply` | `--force` / `-f` | — | Bypass beforeHash check. A file of the patch that is missing on disk is skipped instead of failing the package, and named in a `patch_files_skipped` warning | +| `apply` | `--check` | — | Read-only audit that every in-scope (`--ecosystems`) manifest patch is in place, for CI / GitHub-App auditing (v5.0: previously Go-only, which passed on any unpatched non-Go tree). Local Go patches: the committed `.socket/go-patches/` copies and `go.mod` `replace` directives match the manifest (`go_redirect_drift`). Every other patch: each installed copy hashes to the record's `afterHash` — the `vex` verifier over the `vex` copy lookup; the copies are narrowed by `apply`'s own rules: a release variant (a qualified purl such as `?artifact_id=` / `?platform=`) is judged only on the copies holding its distribution, matched against every variant of its base as `apply` matches them, and an installed copy that holds none of them is drift of the base purl (`no_matching_variant`, the copy `apply` fails with "no matching variant found"; a Gradle / Ivy cache dir is exempt, as in `apply`); for gem, once a bundle-store copy exists the `gem env` fallback-home copies are not judged (`apply` treats them as best-effort). Drift is a `failed` event per patch with `errorCode` `not_applied` (still unpatched), `hash_mismatch` (neither the original nor the patched bytes), `file_not_found` or `no_matching_variant`, status `partialFailure`, exit 1, and the human `Error: Patches are OUT OF SYNC:` report (printed even under `--silent`), which ends with the remedy ``Run `socket-patch apply ` to regenerate them.``: `` repeats the check's own `--cwd`, `--manifest-path`, `-g` / `--global-prefix` and `--ecosystems` (each shell-quoted when needed), so running it verbatim heals the tree that was checked (v5.0, #1219). In sync is exit 0: `Patches are in sync (N checked).` and, under `--json`, a `skipped` event per verified patch (`errorCode: already_patched`). A record of an ecosystem this CLI does not support is not checked and the line says so instead (`unsupported_ecosystem`); a key that is not a purl is drift (`invalid_purl`). A patch with no installed copy is skipped as `apply` skips it (`package_not_installed`; the human line adds `M not installed, skipped`). Vendor-owned patches are excluded (`vendor --check` audits them). Lock-free, fetch-free, offline-safe; it never writes. An unreadable manifest is drift (`manifest_unreadable`, exit 1) | | `vendor` | `--force` / `-f` | — | Bypass the installed-variant probe for multi-release ecosystems. That is its only effect: vendoring commits the patch server's verified artifact and never reads the installed files' content, so a missing or mismatched installed file vendors the same with or without `--force` (no warning) | | `vendor` | `--revert` | `SOCKET_VENDOR_REVERT` | Undo vendoring: restore recorded original lockfile fragments + remove `.socket/vendor/` artifacts. Works without a manifest. A package vendored over a hosted pin returns to its upstream registry entry, never to hosted (see "Takeover reconciliation") | | `vendor` | `--check` | — | Offline, read-only artifact and wiring audit; exits 1 on drift. Conflicts with `--revert`. | @@ -294,7 +308,7 @@ Beyond the globals above, each subcommand defines a small set of local arguments Each matching package instance is spliced, including scoped, quoted and nested-peer keys, one `redirect_pnpm_resolution` edit per changed instance (`rollback` / `remove` restore each from the npm registry — see "Hosted unwind coverage"). LF/CRLF and unrelated lock bytes are preserved. Unsupported matching instances refuse that dependency across the lockfile set; an already-hosted URL elsewhere cannot confirm a partial rewrite. -For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes). An existing file with no keys at all — empty, comments only, bare `---` / `...` / `%` directive lines — reads to pnpm like no file, but pnpm 8.x–10.4 refuse every command once it holds a key without `packages` ("packages field missing or empty", #1096), so it gets the same root-only `packages:` scaffold spliced in with the key, and follows the no-file rules below. pnpm 8–10.4 read a keyless file as every nested package (pnpm >= 10.5 as the root alone), so when `pnpm-lock.yaml` lists importers besides `.` the file is left untouched and the warning says to list the members under `packages:` after upgrading to pnpm >= 11 (or install with `--trust-lockfile`); the vendored mirror skips the file the same way. A keyless file holding several documents is not spliced (the shapes rule below). A project with no `pnpm-workspace.yaml` (or a keyless one) whose every pnpm pin — the installed `node_modules/.modules.yaml` `packageManager`, and package.json `packageManager`, `devEngines.packageManager` and `engines.pnpm` (at least one present) — names pnpm 9.0–10.4 gets no file (#734): those releases never read the key, and a created root-only workspace makes `pnpm add ` fail with ERR_PNPM_ADDING_TO_ROOT there; the warning names the pins and says to re-run after upgrading to pnpm >=11 (or install with `--trust-lockfile`). A pin naming a later pnpm, a range reaching past 10.4, an unparseable pin, or no pin keeps the scaffold, whose warning notes that pnpm 9.0–10.4 then need `pnpm add -w ` and that pinning that pnpm in package.json `packageManager` (then deleting the file and re-running) avoids it — provided every other pin names 9.0–10.4 too (the "every pin" rule above). **Known residual (#734)**: an unpinned project with no install record (no `node_modules/.modules.yaml`, no package.json pnpm pin — e.g. a fresh clone) still gets the root-only scaffold, since nothing shows its pnpm cannot read it; on pnpm 9.0–10.4 such a project needs `pnpm add -w ` to add dependencies, or a `packageManager` pin naming that pnpm so the scaffold is never created. pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings (an existing top-level key in any YAML spelling: quoted, `trustLockfile :`, with a trailing comment; and a `pnpm_config_trust_lockfile` / `PNPM_CONFIG_TRUST_LOCKFILE` environment variable set to anything but `true` when the project's pnpm is >=11 or unknown, which pnpm reads ahead of the file, so nothing is written and the warning names the variable, #1514), and is disabled by `--no-trust-lockfile-config`. The key goes inside the document (before a `...` end marker); a file a line append would corrupt (a flow-style root, an indented root, several documents) is left untouched and the warning gives the manual recoveries. The vendored `overrides:` mirror in `pnpm-workspace.yaml` reads keys the same way and refuses those shapes before writing. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. When every lock the run spliced is a Rush lock, the same warning code carries the Rush remedy instead (rush runs pnpm in `common/temp`, so neither the repo-root key nor `--trust-lockfile` reaches it): `pnpm_config_trust_lockfile=true rush install`, plus `"usePnpmFrozenLockfileForRushInstall": true` in `common/config/rush/experiments.json` on pnpm 11 (whose default Rush install otherwise re-resolves the hosted entries to upstream with exit 0), and `rush purge` before reinstalling. A re-run over a 9.0 Rush lock an earlier run already redirected (nothing spliced) re-issues the same Rush remedy; a run that also spliced a non-Rush pnpm lock keeps the generic text plus a Rush note. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. +For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLockfile: true` (created with a root-only `packages:` scaffold, or appended while preserving user bytes; the added lines end as the file's own lines do, so a CRLF file stays CRLF). An existing file with no keys at all — empty, comments only, bare `---` / `...` / `%` directive lines — reads to pnpm like no file, but pnpm 8.x–10.4 refuse every command once it holds a key without `packages` ("packages field missing or empty", #1096), so it gets the same root-only `packages:` scaffold spliced in with the key, and follows the no-file rules below. pnpm 8–10.4 read a keyless file as every nested package (pnpm >= 10.5 as the root alone), so when `pnpm-lock.yaml` lists importers besides `.` the file is left untouched and the warning says to list the members under `packages:` after upgrading to pnpm >= 11 (or install with `--trust-lockfile`); the vendored mirror skips the file the same way. A keyless file holding several documents is not spliced (the shapes rule below). A project with no `pnpm-workspace.yaml` (or a keyless one) whose every pnpm pin — the installed `node_modules/.modules.yaml` `packageManager`, and package.json `packageManager`, `devEngines.packageManager` and `engines.pnpm` (at least one present) — names pnpm 9.0–10.4 gets no file (#734): those releases never read the key, and a created root-only workspace makes `pnpm add ` fail with ERR_PNPM_ADDING_TO_ROOT there; the warning names the pins and says to re-run after upgrading to pnpm >=11 (or install with `--trust-lockfile`). A pin naming a later pnpm, a range reaching past 10.4, an unparseable pin, or no pin keeps the scaffold, whose warning notes that pnpm 9.0–10.4 then need `pnpm add -w ` and that pinning that pnpm in package.json `packageManager` (then deleting the file and re-running) avoids it — provided every other pin names 9.0–10.4 too (the "every pin" rule above). **Known residual (#734)**: an unpinned project with no install record (no `node_modules/.modules.yaml`, no package.json pnpm pin — e.g. a fresh clone) still gets the root-only scaffold, since nothing shows its pnpm cannot read it; on pnpm 9.0–10.4 such a project needs `pnpm add -w ` to add dependencies, or a `packageManager` pin naming that pnpm so the scaffold is never created. pnpm >=11 requires this to accept hosted URLs; it disables registry re-verification for the whole lock, while sha512 tarball integrity remains enforced. The write (edit kind `redirect_pnpm_workspace_trust`) respects `--dry-run`, skips legacy locks and Rush repos, preserves explicit user settings (an existing top-level key in any YAML spelling: quoted, `trustLockfile :`, with a trailing comment; and a `pnpm_config_trust_lockfile` / `PNPM_CONFIG_TRUST_LOCKFILE` environment variable set to anything but `true` when the project's pnpm is >=11 or unknown, which pnpm reads ahead of the file, so nothing is written and the warning names the variable, #1514), and is disabled by `--no-trust-lockfile-config`. The key goes inside the document (before a `...` end marker); a file a line append would corrupt (a flow-style root, an indented root, several documents) is left untouched and the warning gives the manual recoveries. The vendored `overrides:` mirror in `pnpm-workspace.yaml` reads keys the same way and refuses those shapes before writing. The `redirect_pnpm_trust_lockfile` warning explains manual configuration when required and clean reinstall guidance for all pnpm versions. When every lock the run spliced is a Rush lock, the same warning code carries the Rush remedy instead (rush runs pnpm in `common/temp`, so neither the repo-root key nor `--trust-lockfile` reaches it): `pnpm_config_trust_lockfile=true rush install`, plus `"usePnpmFrozenLockfileForRushInstall": true` in `common/config/rush/experiments.json` on pnpm 11 (whose default Rush install otherwise re-resolves the hosted entries to upstream with exit 0), and `rush purge` before reinstalling. A re-run over a 9.0 Rush lock an earlier run already redirected (nothing spliced) re-issues the same Rush remedy; a run that also spliced a non-Rush pnpm lock keeps the generic text plus a Rush note. Existing installs and warm stores can retain upstream files; use a clean install tree and empty store, then verify installed files with `socket-patch vex`. Neither a successful install nor a local VEX export guarantees hosted SBOM recognition or changes dashboard alert actions/counts. **npm hosted-mode `allow-remote` contract**: npm >=12 defaults `allow-remote=none` and refuses (EALLOWREMOTE) every lockfile entry whose `resolved` tarball is not served by the configured registry — exactly what a hosted redirect writes into `package-lock.json` / `npm-shrinkwrap.json`. Whenever a run leaves a ROOT npm lock carrying a granted hosted artifact URL (spliced this run, or already redirected by an earlier one — a missed config heals on re-run), the CLI ensures `allow-remote=all` in the project-root `.npmrc`: the file is created holding exactly `allow-remote=all\n` when absent, otherwise one `allow-remote=all` line is spliced in after the last non-empty top-level line (before any ini `[section]` header), in the file's own line ending, with the BOM, CRLF and trailing-newline shape preserved. The write lands in `redirect.rewrittenFiles` (edit kind `redirect_npmrc_allow_remote`), respects `--dry-run` (nothing written; the warning says what would be — including for a vendored → hosted takeover the dry run only previews), and is disabled by `--no-npm-allow-remote-config` / `SOCKET_NO_NPM_ALLOW_REMOTE_CONFIG`. The `.npmrc` grammar is npm's own `ini` parser's (cross-checked against it): lines split on any run of `\r` / `\n` (a bare `\r` ends a line), only the exact key `allow-remote` counts after ini unquoting (npm ignores `allow_remote` / `ALLOW-REMOTE` in a `.npmrc`; such a line is left alone and the real key appended), comment lines are ignored, a `[section]` header is recognized only as npm does — on the UNTRIMMED line (an indented or BOM-prefixed `[sec]` is a plain top-level key) — and ends the top-level scope, quotes and inline comments are stripped, the LAST top-level assignment wins, and the value is case-sensitive. An explicit other value (`allow-remote=none` / `root` / anything but `all`) is RESPECTED and never rewritten — the pnpm `trustLockfile: false` precedent — in the project `.npmrc` AND in every other npm config layer npm would consult: an `npm_config_allow_remote` environment variable (any spelling npm normalizes; it beats every `.npmrc`, so a project write could not take effect), and — when the project file sets nothing — the user (`npm_config_userconfig` / `~/.npmrc`), global (`npm_config_globalconfig` / `/etc/npmrc`, prefix from `npm_config_prefix`, the user/builtin config, `PREFIX` or the `node` binary's install root) and builtin (npm's own `npmrc` beside the `node` binary: `/lib/node_modules/npm/npmrc`, `\node_modules\npm\npmrc` on Windows) config files — path values `${VAR}`-expanded and `~`-expanded like npm, env names case-insensitive on Windows, where a committed project line would silently override a machine / org policy. A symlinked, non-regular or unreadable `.npmrc`, or one with bare-`\r` line endings (npm splits on them, the line splice does not), is left untouched. Every variant emits the `redirect_npm_allow_remote` warning (written / would write / already set / explicit value respected — naming the project file, the env var, or the user/global/builtin config path — / opted out / unreadable or unsupported), always with the tradeoff: `allow-remote=all` lets npm install ANY url-resolved dependency, not just Socket's patched ones, while the per-entry sha512 integrity pins stay enforced; the remedy for the non-writing variants is `allow-remote=all` in `.npmrc` or `npm ci --allow-remote=all`. npm <=11 is unaffected (11 defaults to `all`, <=10 has no such setting). **Unwind (v5.0: no ledger)**: once `rollback`, `remove` or a hosted → vendored takeover has restored the last hosted entry of the root `package-lock.json` / `npm-shrinkwrap.json` to its upstream registry entry (see "Hosted unwind coverage"), a project `.npmrc` holding exactly `allow-remote=all\n` (the file hosted mode creates) is deleted; any other `.npmrc` that still has a top-level `allow-remote=all` line is left untouched and the `npm_allow_remote_left` warning says the line may be removed if nothing else needs it (v5 keeps no record of whether hosted mode added it, so it is never removed behind the user's back). The rewrite's stage file is created with the `.npmrc`'s own permission bits (a 0600 token-bearing file is never staged world-readable). **Vendored mode is unaffected by `allow-remote`**: its `file:.socket/vendor/…` resolutions are npm `file` specs, which npm >= 11.14 gates by `allow-file` (default `all`), never `allow-remote` — verified by the real npm 12 vendored matrix. **npm vendored `allow-file` contract (#969)**: `allow-file=none` refuses every `file:` dependency, and any other non-`all` value (`root`) admits one only when the project root or a workspace declares it and resolves to that very lock node (arborist's `_isRoot`); otherwise every `npm ci` / `npm install` fails EALLOWFILE. The effective value is read from the same npm config layers as `allow-remote` (an `npm_config_allow_file` environment variable, then the project `.npmrc`, then the user / global / builtin config files). When it refuses a `package-lock.json` / `npm-shrinkwrap.json` instance of a vendored `name@version` (the set vendoring rewires), the vendored run (`vendor`, `scan` / `get --mode vendored`, dry runs and in-sync re-runs included) still vendors, leaves the setting untouched, and records the `vendor_npm_allow_file` advisory naming the setting's source, the refused lock entries and the remedy (`allow-file=all` in the project `.npmrc`, unsetting the env var, or `npm ci --allow-file=all`); `vendor --check` fails that entry `vendor_check_failed` with the same reason. Other npm-family flavors (yarn, pnpm, bun, vlt) do not read `allow-file` and are not checked. @@ -304,7 +318,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **pnpm per-member locks (#492)**: a workspace whose root `pnpm-workspace.yaml` sets `sharedWorkspaceLockfile: false` (or, when the YAML leaves the key unset, whose root `.npmrc` sets `shared-workspace-lockfile=false`; on disk, when neither file sets it, `pnpm_config_shared_workspace_lockfile` on pnpm 11 and newer, `npm_config_shared_workspace_lockfile` on pnpm 10 and older, either when the major is unknown (#1460, the major as for the `lockfile` setting below); booleans in any letter case, as pnpm's YAML reader takes them) installs each member from the member's own `pnpm-lock.yaml`. Run from the workspace root, hosted mode reads every member lock the `packages:` globs name (quoted `!` negations applied; `node_modules`, `bower_components`, `test` and `tests` trees and symlinked directories skipped) beside the root lock, which may be absent (pnpm 7): each is rewritten like the root lock, a 9.0 member lock engages the `trustLockfile: true` auto-config in the ROOT `pnpm-workspace.yaml` (heal-on-rerun included), and discovery (`list`, `vex`, `rollback`, `remove`) reads the same member locks. A root lock whose `importers:` lists a member is a shared lock, so member locks beside it are stale and never read or attested. When the members' locks cannot be listed or read (no `packages:` key, whose pnpm <= 8 default finds projects in every directory; a `packages:` value that is not a plain list of globs; no member lock and no root lock, or a member lock that exists but is not readable text), every pnpm lock (the root's included) is left untouched and the run warns `redirect_pnpm_member_locks_unresolved` instead of `redirect_pnpm_no_lockfile`; no pnpm pin is confirmed. The lockfile inventory reads the same member locks, so their dependencies are discovered without an installed tree. The in-memory engine gives the same result as a disk run from the workspace root: a directory whose `pnpm-lock.yaml` has no `pnpm-workspace.yaml` beside it is a workspace member, never a root of its own, when (with `npm` among the `ecosystems`) the nearest ancestor `pnpm-workspace.yaml` is readable and its `packages:` globs (read as above, no braces, classes or extglobs) list it, it holds a `package.json` / `package.yaml` / `package.json5`, the workspace root installs it from its own lock (`sharedWorkspaceLockfile: false` with the member list resolved) or from a root lock beside which its lock is a stale leftover, and socket.yml admits the workspace root. Its lock is then demoted into that workspace root, which becomes a root even with no lock of its own (pnpm 7), the way a Cargo member's lock is demoted; the member stays a root only when it holds another lock marker, without its pnpm lock. The workspace root then pins (or refuses) its member locks as above, trusts them in its own file, refuses a member's branch lock (#556) and leaves a member lock beside a shared root lock unread. A directory that fails any of those tests keeps its lock as a root of its own (pinned like any root, or refused with `redirect_pnpm_settings_elsewhere` while the trust auto-config is on and the ancestor file may list it). Path selection adds the workspace root above every candidate to `roots`, fetches it, and fetches the member locks, branch locks and manifests below it in every directory holding a package manifest; given those `roots` as `projectRoots`, the session drops a confirmed member named beside its workspace root (unless it holds another lock) and a lockless workspace root that pins no member, so it runs the roots it would detect. A confirmed member named in `projectRoots` without its workspace root is not pinned: its lock is dropped and the run-level `pnpm_member_lock_ignored` warning names the workspace root to scan. -**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads the setting from its environment, in the one variable family that major reads (#1460): `pnpm_config_lockfile` / `pnpm_config_package_lock` on pnpm 11 and newer, `npm_config_lockfile` / `npm_config_package_lock` on pnpm 10 and older, either family when the major is unknown (so the `npm_config_package_lock=` that `npx` / `npm exec` export from an npm-era `.npmrc` refuses nothing on pnpm 11+). Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. +**pnpm with its lockfile off (#1074)**: pnpm's `lockfile` setting off makes pnpm neither read nor write `pnpm-lock.yaml` (nor a member's own lock): it resolves every dependency from the registry, so a hosted pin in a committed lock never installs. Each pnpm major reads the setting from one file: pnpm 11 and newer a top-level `lockfile: false` in the project's `pnpm-workspace.yaml`, pnpm 10 and older `lockfile=false` in its `.npmrc`; when `lockfile` is unset (or not a boolean) pnpm falls back to npm's `package-lock` (`packageLock: false` / `package-lock=false`), which counts the same way. The major is the one `node_modules/.modules.yaml` records, else package.json's `packageManager` pin, else what the root lock's grammar implies (a pre-9 lock or `shrinkwrap.yaml`: pnpm 8 or older; an env lockfile document: pnpm 11 or newer); when none says, either file turning it off counts. Hosted mode also reads the setting from its environment, in the one variable family that major reads (#1460): `pnpm_config_lockfile` / `pnpm_config_package_lock` on pnpm 11 and newer, `npm_config_lockfile` / `npm_config_package_lock` on pnpm 10 and older, either family when the major is unknown (so the `npm_config_package_lock=` that `npx` / `npm exec` export from an npm-era `.npmrc` refuses nothing on pnpm 11+). Only the project's own files are read (a workspace member with no settings file of its own does not inherit its root's). In a pnpm project (a root pnpm lock or a `pnpm-workspace.yaml`), hosted mode then leaves every pnpm lock and `pnpm-workspace.yaml` untouched (no `trustLockfile` key is added), confirms no npm pin from them and warns `redirect_pnpm_lockfile_disabled` (in place of `redirect_pnpm_no_lockfile`), exiting 0 like the per-branch lock refusal below; the detail names the setting, and the remedy is `--mode vendored` when a pnpm lock exists (pnpm applies the `overrides` it wires without a lock), else removing the setting and running `pnpm install`. The check comes before the per-branch lock check. Manifest-less `vex` keeps the hosted references of such a lock but withholds their lockfile basis (see the pnpm row of the discovery table). Rush locks are not affected: Rush runs pnpm with its own settings. Vendored mode is unchanged. `vex` attests a vendored reference of such a lock only while the override map the governing pnpm reads without a lock routes the package (`name@version` or bare `name`) to the same artifact: package.json `pnpm.overrides` when `.npmrc` turns the lock off (pnpm 10 and older; a pinned pnpm 10 also reads the `pnpm-workspace.yaml` map), `pnpm-workspace.yaml` `overrides` when that file does (pnpm 11 and newer never read `pnpm.overrides`), both when both do and no major is known. Otherwise pnpm installs the registry release and the patch is omitted (`vex_lock_not_read`, see **Locks the package manager is told not to read**). **pnpm per-branch locks (#556)**: with `gitBranchLockfile` on (a top-level `gitBranchLockfile: true` in the root `pnpm-workspace.yaml`; when the YAML leaves the key unset, `git-branch-lockfile=true` in the root `.npmrc`, as pnpm 10 and older read it; on disk also `pnpm_config_git_branch_lockfile` (pnpm 11 and newer) or `npm_config_git_branch_lockfile` (pnpm 10 and older; either when the major is unknown, read as for the `lockfile` setting above) set to `true`; a workspace member with no `pnpm-workspace.yaml` of its own reads the governing ancestor's file and the `.npmrc` beside it), pnpm installs a git branch from its own `pnpm-lock..yaml` whenever that lock exists, in every directory it installs a lock from: the root, and under `sharedWorkspaceLockfile: false` each member's (#492). When the setting is on AND at least one such `pnpm-lock..yaml` exists (at the root, or in a member that installs from its own lock), hosted mode leaves every root pnpm lock (`pnpm-lock.yaml`, `shrinkwrap.yaml`) and `pnpm-workspace.yaml` untouched (every member lock too), confirms no npm pin from them and warns `redirect_pnpm_git_branch_lockfile` (in place of `redirect_pnpm_no_lockfile` when the branch lock is the only one); the detail names the setting and the branch lock(s), and the remedy is to turn `gitBranchLockfile` off and fold the branch locks into `pnpm-lock.yaml` with `pnpm install --merge-git-branch-lockfiles`, then re-run. A vendored→hosted takeover of a pnpm-vendored purl is refused with the same code (the vendored wiring and ledger stay byte-for-byte). Vendored mode refuses the same projects with `vendor_pnpm_git_branch_lockfile`, before any write and in the pre-flight. The setting with no branch lock (main, or a branch whose dependencies never changed) installs from `pnpm-lock.yaml`, which both modes wire as usual; a stray branch lock with the setting off is ignored. The in-memory engine fetches root `pnpm-lock.*.yaml` names presence-only and refuses the same way. `vex` omits every pin of those pnpm locks (`vex_pnpm_git_branch_lockfile`) whenever such a branch lock exists, whatever the setting says (fail-closed; read from the project's files only, not the environment); see **Unattested references**. @@ -314,14 +328,16 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Takeover reconciliation (every hosted ecosystem, v5.0)**: plain `vendor` with at least one hosted pin in scope is not a per-purl takeover: it EJECTS exactly the lockfile's hosted pins as one planned transition (see "Vendor command contract") and ignores `.socket/manifest.json` even when it exists — a manifest-only patch is not vendored, and a manifest naming a different patch for a hosted purl does not displace the hosted one (vendoring the manifest's patches takes a full `scan --mode vendored`, whose edits then land in the lockfiles). The eject runs the same upstream restore and the same backend previews and refusals described below, but reports them as an eject (`eject_planned` / `eject_refused` / `eject_rolled_back`) rather than through the per-purl takeover advisories. Vendoring over a hosted pin with `scan --mode vendored` / `get --mode vendored` (and the legacy manifest-driven `vendor` path, reached only when no hosted pin is in scope) first RESTORES that purl's lock entries to their default upstream registry entry — the same restore `rollback` runs (core `patch::redirect::upstream::restore_upstream`; see "Hosted unwind coverage"), over the hosted pins lockfile discovery finds (v5 keeps no hosted ledger) — and then vendors, so the vendor ledger records the PRISTINE registry entry as its wiring `original` and `vendor --revert` lands back on upstream registry state, never on hosted. The run that takes over records a `vendor_takeover_reverted_redirect` advisory event (`skipped` action beside the purl's genuine outcome; detail ` was hosted; restored its upstream registry entry () before vendoring (mode takeover)`; the human path prints `Warning: …`), plus any advisory the restore raised (`npm_allow_remote_left`, …). **A takeover the vendored backend does not carry through keeps the hosted pin (#853, #944)**: the wet run holds the restore in its group commit, and when the backend then refuses the purl — whatever the code: a pnpm `catalog:` dependency (`vendor_lock_entry_unsupported`), a CRLF `pnpm-lock.yaml` (`vendor_lockfile_crlf_unsupported`), a conflicting `pnpm-workspace.yaml` override — a range, another version or a `>` selector (`vendor_override_conflict`), a uv inline `[tool.uv] sources` table, a prebuilt download that fails, … — or its apply fails with nothing recorded, the restore is rolled back before anything reaches disk: the purl is reported `failed ` with the backend's own code and detail, neither `vendor_takeover_reverted_redirect` nor the restore's advisories are recorded for it, and the hosted wiring stays byte-for-byte (exit 1 / `partialFailure`), so the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed ` by running the backend's dry run over the restored project staged in memory (nothing written). A restore that writes a file outside the group commit's captured set (`.socket/gradle/hosted-index.tsv`) is not rolled back. The `scan` / `get --mode vendored --dry-run` preview, which stages no restore (it never touches the registry), predicts the pnpm (lockfileVersion 9) part of this (#853): a hosted pin the pnpm vendored backend refuses on lock or manifest text (a `catalog:` dependency, a CRLF `pnpm-lock.yaml` / `pnpm-workspace.yaml`, a conflicting override, …) is listed `would_refuse` with the wet run's `errorCode` and the backend's own `error` detail, evaluated on the still-hosted project (the pnpm restore only rewrites the entry's `resolution:`, which none of those gates reads); a hosted pin of any other lock flavor or ecosystem — a legacy pnpm 7/8 lock (`lockfileVersion` 5.4 / 6.0) included, whose CRLF, override-conflict and entry refusals are not lock-text gated — still previews `would_vendor` even when the wet takeover refuses it (the wet refusal keeps the hosted pin), except for the gem preflight below. **Gem preflight before the takeover**: `scan` / `get --mode vendored` ask the gem vendored backend's own refusals BEFORE the upstream restore — the manifest gate (`gemfile_not_loaded`: a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest) and the Gemfile declaration gate evaluated on the Gemfile and Gemfile.lock text the restore would leave (`gemfile_declaration_not_editable`: a declaration inside a `group` / `platforms` / conditional block, a parenthesized or duplicate declaration, …) — so a hosted gem vendored mode cannot wire is reported `failed ` with the hosted `Gemfile` / `Gemfile.lock` byte-untouched (exit 1 / `partialFailure`), and their `--dry-run` preview reports that gem as `would_refuse` with the same `errorCode` (exit 0, like the Bun / vlt `would_refuse` rows), never `would_vendor`. Pinned against real Bundler by `tests/e2e_redirect_gem_build.rs`. `--dry-run` resolves the same restore without writing (registry lookups included): a pin that would restore reports `vendor_would_revert_redirect`, and one that would be refused surfaces in the preview with the wet run's `redirect_revert_failed` code and detail (for bun, whose hosted rewrite replaces the entry's `name@version` spec, the preview first runs the Bun vendored preflight described below and then stops at the advisory instead of reading the still-hosted lock — a lock the vendored backend would refuse is previewed as the wet run's `failed `, never as `vendor_would_revert_redirect`). A purl whose upstream entry cannot be restored — `--offline`, a registry that does not answer, a lock the restore refuses (see "Hosted unwind coverage"; a hosted binary `bun.lockb` pin IS restored for the takeover — its npm registry record is rebuilt natively — while `rollback` / `remove` refuse it) — fails `redirect_revert_failed` with the detail `cannot vendor over the live hosted pin: cannot restore to its upstream registry entry: ; restore it from version control instead (`git checkout -- `)` (for `bun.lock` / `bun.lockb` the detail also adds `, then run \`bun install --force\` (a plain \`bun install\` keeps the patched copy)`) (exit 1 / `partialFailure`, nothing vendored for it, the hosted wiring left in place). The cargo backend's `hosted_redirect_live` refusal backstops a crate whose hosted residue is still in place when it is reached; its detail names `socket-patch rollback` and `git checkout -- Cargo.toml Cargo.lock`. **Bun vendored preflight before the takeover**: `vendor` — like `scan` / `get --mode vendored`, whose pre-download preflight runs earlier — checks `bun.lock` / `bun.lockb` with the shared Bun vendored preflight BEFORE the upstream restore, so a hosted purl on a lock the vendored backend refuses (a pre-version-2 `workspace:` lock → `vendor_bun_workspace_unsupported`; a malformed or unsupported binary lock → `vendor_bun_lockb_invalid`; an unsupported text-lock version → its code) is reported `failed ` with the hosted wiring and active Bun lock byte-untouched (exit 1 / `partialFailure`): the package stays hosted-patched instead of being un-hosted and then refused. `vendor --dry-run` previews that same `failed` code (exit-code parity with the wet run, nothing written) instead of promising `vendor_would_revert_redirect`. Pinned by `tests/in_process_vendor_bun_takeover.rs` and, against real Bun, `tests/mode_migration_bun.rs`. The npm package-lock backend's lock gate gets the same placement: a hosted pin in a project whose `npm-shrinkwrap.json` / `package-lock.json` is not a v2/v3 lock (npm 6's lockfileVersion 1) is refused `failed vendor_lockfile_version_unsupported` BEFORE the restore, in `vendor`, `scan --mode vendored` and `get --mode vendored` alike, so the package stays hosted-patched; the vendored dry-run preview lists every npm purl of such a project as `would_refuse` with that code. Pinned by `tests/in_process_vendor_npm_v1_takeover.rs`. Hosted → vendored and vendored → hosted (`redirect_takeover_reverted_vendored` in `warnings[]`) both work in place on the locks the target mode accepts. **Removed in v5.0**: the run-level `vendor_supersedes_redirect` warning and its reconcile of the redirect ledger (a live lock that already proved vendored won over a stale hosted ledger record) — once the lock routes a package to `.socket/vendor/`, no hosted state is left to go stale. Which way the live lock points is decided by the same lockfile discovery rules `vex` gates attestations on (see "Manifest-less VEX (lockfile discovery)"), for `redirect_supersedes_vendored` and `hosted_wiring_retained` alike. -**Withdrawn pins (`scan --mode hosted`, additive/MINOR)**: before the rewrite, a hosted scan checks every patch uuid the lockfiles pin, for a package this run covered, against the reference endpoint. A pin the server reports `withdrawn` (unpublished) or `not_found` (no such patch) no longer installs (the patch server answers 410), so it is restored to its default upstream registry entry: the same restore `rollback` runs (see "Hosted unwind coverage"), under the apply lock, `--dry-run` previewing it without writing. If the package has a replacement patch on offer, the rewrite that follows pins the replacement. Each restored pin is one `rolledBack` event (`verified` on a dry run) with `uuid`, `errorCode: "patch_withdrawn"` and `details.mode: "hosted"`; the restore's advisories (`redirect_bun_reinstall_required`, `redirect_pypi_reinstall_required`, `redirect_vlt_reinstall_required`) join `warnings[]`. Only those two statuses unpin: a pin merely absent from the search results (a failed batch, the free-only proxy fallback, a lapsed paid entitlement), or one reported `forbidden`, `pending_build` or `build_failed`, is kept. If the reference lookup fails, every pin is kept with a `redirect_withdrawn_check_failed` warning; a pin the restore refuses (or the apply lock being held) is kept with a `redirect_withdrawn_restore_failed` warning naming the remedy. Neither changes the status or the exit code. A run whose every search batch failed exits before the check. +**Withdrawn pins (`scan --mode hosted`, additive/MINOR)**: before the rewrite, a hosted scan checks every patch uuid the lockfiles pin, for a package this run covered, against the reference endpoint. A pin the server reports `withdrawn` (unpublished) or `not_found` (no such patch) no longer installs (the patch server answers 410), so it is restored to its default upstream registry entry: the same restore `rollback` runs (see "Hosted unwind coverage"), under the apply lock, `--dry-run` previewing it without writing. If the package has a replacement patch on offer, the rewrite that follows pins the replacement. Each restored pin is one `rolledBack` event (`verified` on a dry run) with `uuid`, `errorCode: "patch_withdrawn"` and `details.mode: "hosted"`; the restore's advisories (`redirect_bun_reinstall_required`, `redirect_pypi_reinstall_required`, `redirect_vlt_reinstall_required`) join `warnings[]`. Only those two statuses unpin: a pin merely absent from the search results (a failed batch, the free-only proxy fallback, a lapsed paid entitlement), or one reported `forbidden`, `pending_build` or `build_failed`, is kept. If the reference lookup fails, every pin is kept with a `redirect_withdrawn_check_failed` warning; a pin the restore refuses, or whose restored files cannot be written (or the apply lock being held), is kept with a `redirect_withdrawn_restore_failed` warning naming the remedy, and is never reported as `rolledBack`: the write is all or nothing (see "Hosted unwind coverage"), so a failed one leaves every pin of that restore in place. Neither changes the status or the exit code. The one exception is a write that failed part-way and could not put a file back: the lockfiles are then neither pinned nor restored, so the run stops before the rewrite with the top-level error `lockfile_write_failed` (exit 1), whose message names each such file and the state it is left in. A run whose every search batch failed exits before the check. ### Scan modes (v5.0) -**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. So is a vendor ledger beside hosted pins in the project's lockfiles (#1437), the mixed state a partial `--mode hosted` takeover or per-package `get --mode` leaves: hosted mode keeps no ledger, so its pins are its state, and keeping vendored mode would take every pin over. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. +**Mode resolution (`resolve_mode_flags`, MAJOR in v5.0).** `--mode`, or `--sync` (agent mode), picks the mode. With neither, `scan` and `get` keep the mode the project's patch state already records (`mode_from_project_state`, #1088; both stores are read from the project the resolved manifest belongs to, so a `--manifest-path` into another project consults that project's ledger): a non-empty vendor ledger (`.socket/vendor/state.json`; also an unreadable or malformed one, so the vendored flow reports it) means **vendored**, a manifest holding patches the ledger does not already cover (by key or base purl), or an unreadable or malformed manifest (so the agent flow reports it), means **agent** — a record the ledger covers, as `get --save-only` then `vendor` leaves, is vendored state — and a project with neither runs **hosted** mode — JSON and human alike (see the hosted paragraph below and [`scan` and `get` JSON](#scan-and-get-json-v50-major)). A project holding both agent and vendored state is a usage error (exit 2, code `mode_ambiguous`, naming the three `--mode` values) and writes nothing. So is a vendor ledger beside hosted pins in the project's lockfiles (#1437), the mixed state a partial `--mode hosted` takeover or per-package `get --mode` leaves: hosted mode keeps no ledger, so its pins are its state, and keeping vendored mode would take every pin over. A human-mode run that kept vendored or agent mode says so on stderr (`Note: using --mode vendored because … already holds vendored patches; pass --mode explicitly to switch modes`); `--json` and `--silent` print no note. **Switching modes is always explicit**: the in-place vendored → hosted takeover (see **Staged takeover**) runs only under `--mode hosted`, the hosted → vendored takeover only under `--mode vendored`, and a bare `scan`/`get` never converts a project. With hosted/vendored PATH arguments and no `--mode`, each project directory takes its mode from its own state. The one exception to the state-derived default: a `--prune` or `--global`/`--global-prefix` scan with no mode has no project lockfile to rewire, so it is **report-only** — discovery, the table, the `updates` array and the `redirectState` block below, plus the `--prune` GC — and, in human mode, ends with the hint `To apply these patches in place, run:` / ` socket-patch scan --mode agent [PATHS]` / ` socket-patch get `. Its per-patch listing is headed `Patches available:` (`Patches to apply:` belongs to a run that applies them), and its `--dry-run` prints the same listing and hint as the run it previews, with the `--prune` GC as a read-only preview: a report-only preview never says `Would download and apply N patches`. A global scan's hint carries the run's scope, so it can be run verbatim: `-g` (`scan --mode agent -g` / `get -g <…>`), or `--global-prefix ` when a prefix was given (the directory shell-quoted when it needs it). An explicit `--mode hosted` or `--mode vendored` with `--global`/`--global-prefix` is a usage error (exit 2: global installs have no project lockfile to redirect, or to wire vendored artifacts into); `get` enforces the same rule with the same wording. **Global scope never touches the project's state (v5.0).** A `--global`/`--global-prefix` run that starts inside a project acts on the global installs only. The `--cwd` project's hosted pins and vendor ledger are not its target: `rollback` and `remove` run no hosted or vendored leg (they restore the global copies and drop their manifest records; `rollback` keeps the manifest records of purls the project vendors), a pre-v5 hosted ledger is never retired, and the project's vendor ledger does not own the global copies, so `apply` and `scan --mode agent` patch the global copy of a purl the project vendors (no `vendored` skip, no `vendored_ownership_retained` warning). One global store is the project's own install: a NuGet project restores into the global packages folder, so when the global run's NuGet folder is one the project restores into, the project's vendored NuGet packages there stay the ledger's (#489). `apply` and `scan --mode agent` skip them as `vendored` (scan adds `vendored_ownership_retained`), and `rollback` and `remove` leave them patched, each reported as a `skipped` / `vendored` event (human: ` [skip] (vendored: …)`); `rollback` keeps their manifest records, as above. The project's folders are the ones its restores recorded (`obj/project.assets.json` `packageFolders`), plus the global packages folder while a project under it has no restore yet, so a project that restores into `RestorePackagesPath` leaves the global folder's copies to the global run. The standalone `vendor` command acts only on the project, so every form of it (plain, `--revert`, `--check`) is a usage error under global scope: exit 2, human `Error: cannot be used with vendor[ --revert| --check]: global installs have no project lockfile to …`, JSON `{status: "error", error: {code: "global_scope_unsupported", message}}`, checked before the project is read or locked (#498). +**Where discovery probes run.** The tools socket-patch asks for an install location run from a directory chosen by the question, never from wherever the CLI was started. A question about the project (`gem env` for a Bundler project without a `vendor/bundle` store, the `python3` whose site-packages a project without its own environment installs into) runs from `--cwd`, so a version manager's shim (rbenv, pyenv, asdf, mise) follows that project's `.ruby-version` / `.python-version` / `.tool-versions`. A question about the global install (`npm root -g`, `yarn global dir`, `pnpm root -g`, `bun pm`, `composer global config home`, and `gem env` / `python3` under `--global`) runs from the user's home directory (the drive root when there is none), so no project's files steer a global scan. + **pnpm 11+ global installs are one root each.** pnpm 11+ gives every `pnpm add -g` its own install, `$PNPM_HOME/global/v//node_modules` (each with its own `.pnpm`), and `pnpm root -g` prints their parent. `--global` and a `--global-prefix` naming such a directory (a `v` dir that is not itself a `node_modules` and has at least one child install carrying pnpm's `.modules.yaml`) crawl every child `node_modules` as a separate root, so `apply`, `rollback`, `scan` and `vex` see every install's copy of a package, not just the first install's (#435). One physical copy two installs both link is still visited once. Any other prefix (pnpm <= 10's `global/5/node_modules`, npm's) is crawled as the single root it names. **Deno's npm cache is a global root (additive/MINOR, #444).** `deno install -g` tools load their npm dependencies from Deno's shared npm cache, `$DENO_DIR/npm////`, where `` is the registry's host, plus one dir per path segment for a registry served below a path (`https://host/api/npm/` → `/api/npm`) (scoped: `@scope/name//`; `DENO_DIR` defaults to Deno's per-platform cache dir). `--global` crawls every such registry dir under `$DENO_DIR/npm` (all but `jsr.io`) beside the npm, pnpm, yarn and bun roots, and a `--global-prefix` naming one (a dir below one named `npm`, other than `node_modules` and `jsr.io`, holding some package dir in Deno's layout: no `package.json` of its own, and a cached `registry.json` or a version dir with a `package.json`) is read in that layout instead of as a `node_modules` tree, so `scan`, `apply`, `rollback` and `vex` act on the copy Deno runs. Every copy Deno made of a `name@version` for a different peer-dependency resolution (`_/`) is a copy of it, matched by its `package.json`. The Deno (JSR) crawler finds nothing under such a prefix. @@ -330,13 +346,23 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Hosted-state visibility (`redirectState`, additive/MINOR).** Every non-hosted-mode, non-vendored-mode `scan --json` SUCCESS envelope (report-only, `--mode agent`/`--sync`, and the zero-discovery envelope) carries an additive top-level `redirectState` object whenever the project's lockfiles pin ≥ 1 hosted patch: `{ mode, records: [{purl, uuid}], wiringLive: [purl] }`. It is a descriptive STATE block, not a warning. `mode` is the constant `"hosted"`. **v5.0 (MAJOR shape change)**: hosted mode keeps no ledger, so `records` lists the hosted pins lockfile discovery finds (one per `(purl, uuid)`, the same discovery `vex` uses: a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` origin), and the v4 `ledger` and `records[].ledgerKey` keys are gone. Each record's `purl` is CANONICALIZED (qualifiers stripped, percent-decoded — e.g. `pkg:npm/@scope/pkg@1.0.0`, `pkg:gem/nokogiri@1.13.3`) to the same spelling `wiringLive` carries, so the join is a plain string compare. `wiringLive` is the subset of those pins among this run's *counted* purls (post-`--ecosystems`-filter) — computed once per run, the same set that feeds `hosted_wiring_retained`. A record missing from `wiringLive` is still wired; it just was not crawled/queried this run (an `--ecosystems` filter, a zero discovery). The key is omitted when no lockfile pins a hosted patch (and under `--global`), and error envelopes (the `--offline` refusal, all-batches-failed) are deliberately minimal and never carry it. A pre-v5 `.socket/vendor/redirect-state.json` is not read. Hosted-mode runs carry their own `redirect` payload and `details.mode: "hosted"` events instead, and vendored-mode runs carry the takeover warnings (their takeovers may restore pins mid-run) — neither duplicates a pre-run snapshot that could go stale. -**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (each is also a `skipped` / `vendored` event); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but its ownership could not be put back to the original uid/gid (the mode is still restored last); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. `go_installed_tool_stale` (v5.0; `apply`, dry runs included) — a `--global` Go apply patched a module cache copy that a tool installed with `go install` was built from before the patch; the detail names the binary and the `go install @` remedy (or, when they can't be inspected, what could not be checked); never a status or exit change. `stale_bytecode_not_removed` (`apply` and `rollback` `warnings[]` alike) — a `.py` file WAS patched (or restored) but CPython bytecode compiled from its previous bytes (`__pycache__/.[.opt-N].pyc`) could not be deleted, so Python may keep running the old code until it is removed; the detail is `: could not remove stale Python bytecode (: , …); …` and the human line `Warning: `; never a status or exit change. `cargo_build_cache_stale` (v5.0; `apply` and `rollback` `warnings[]` alike) — a cargo crate's bytes changed but its compiled copy in a project build directory could not be invalidated (a fingerprint directory that could not be removed, or a `build.build-dir` with a `{workspace-path-hash}` template); the detail names the crates to `cargo clean -p` before the next build, which may otherwise link the stale code; never a status or exit change. +**Agent-flow run-level warnings (additive).** An agent-mode apply (`--mode agent` / `--sync`, `--json`) may add a top-level `warnings[]` array of `{code, detail}` entries to the scan envelope (absent when none fired; each is also mirrored to stderr unless `--silent`). They surface cross-mode state the apply cannot change — never a status or exit-code change (hosted refusals set the precedent: exit 0 + warning). Codes (stable; new codes are additive/MINOR): `vendored_ownership_retained` — vendor-owned package(s) were skipped before download (each is also a `skipped` / `vendored` event); the detail names the purls and the migration path (`remove `, or `vendor --revert` which unwinds every vendored package, then re-run). `hosted_wiring_retained` — the lockfiles still pin scanned package(s) to a hosted patch (the agent run does not unwind hosted wiring — as of v5.0 that is `socket-patch rollback`'s job, which restores the upstream registry entries, or `remove ` per package); the detail names the purls and the options (stay `--mode hosted`, migrate via `scan --mode vendored`, or `socket-patch rollback`). The warning keys on the hosted pins lockfile discovery finds at scan time, so a flow that restored the upstream entries retires it. The human path prints the same `hosted_wiring_retained` text to stderr after an apply; the vendored counterpart is already covered by its per-package `[skip] … (vendored …)` lines. `ownership_not_restored` (v5.0; `apply` and `rollback` `warnings[]` alike) — a file WAS patched (or restored) but could not be given its original uid/gid (its mode is kept; the group is kept when the caller belongs to it); the detail is `: : patched, but ownership could not be restored to uid N gid M: ` and the human line `Warning: ` (stderr, muted by `--silent`); never a status or exit change. `go_installed_tool_stale` (v5.0; `apply`, dry runs included) — a `--global` Go apply patched a module cache copy that a tool installed with `go install` was built from before the patch; the detail names the binary and the `go install @` remedy (or, when they can't be inspected, what could not be checked); never a status or exit change. `stale_bytecode_not_removed` (`apply` and `rollback` `warnings[]` alike) — a `.py` file WAS patched (or restored) but CPython bytecode compiled from its previous bytes (`__pycache__/.[.opt-N].pyc`) could not be deleted, so Python may keep running the old code until it is removed; the detail is `: could not remove stale Python bytecode (: , …); …` and the human line `Warning: `; never a status or exit change. `patch_files_skipped` (`apply --force`) — a file of the patch is not on disk and was skipped; the detail is `: N patch file(s) not found on disk and skipped (--force): , …` and the human line `Warning: `. The package's event is `applied` with the files that were written, or, when the run wrote none and the files that exist are patched, `skipped` / `already_patched`; never a status or exit change. `cargo_build_cache_stale` (v5.0; `apply` and `rollback` `warnings[]` alike) — a cargo crate's bytes changed but its compiled copy in a project build directory could not be invalidated (a fingerprint directory that could not be removed, or a `build.build-dir` with a `{workspace-path-hash}` template); the detail names the crates to `cargo clean -p` before the next build, which may otherwise link the stale code; never a status or exit change. -`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. A blob is an orphan when no patch left in the manifest references it: the afterHash and beforeHash blobs of every remaining patch are kept, the same retention policy `repair` uses (the beforeHash blobs are an offline rollback's only restore data, and `repair` downloads afterHash blobs only). Diff and legacy package archives are never kept, even for a remaining patch: nothing reads them any more. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). A Cargo entry is also exempt while its agent-mode copy is still patched: the project crawl looks up only the crates `Cargo.lock` resolves, but a crate the lock bumped or dropped keeps its patched copy in the machine-wide `$CARGO_HOME/registry/src` cache (nothing deletes it), and the entry holds the only blobs that can restore that copy. The wet pass keeps it with a `cargo_cache_patch_kept` warning naming the `socket-patch rollback ` that restores the copy and drops the entry; the `--dry-run` preview reports no event (and no warning) for it. The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). Under `--json` (v5.0, MAJOR) the pass is recorded into the scan envelope: each pruned manifest entry is a `removed` event with `details.manifest: true`, each reverted vendored entry a `removed` event (`errorCode: "vendor_reverted"`, `details.mode: "vendored"`), each drift-kept entry a `skipped` / `vendor_revert_kept` event and each entry whose revert failed a `skipped` / `vendor_revert_failed` event (a GC revert failure never fails the run); a `--dry-run` preview reports the removals as `verified`. The artifact sweep is the envelope's `gc` (`summary.bytesFreed` mirrors it) and the orphan vendor dirs swept are the top-level `removedVendorOrphanDirs` count. The pass's warnings join the top-level `warnings[]`: `vendor_state_write_failed` / `manifest_write_failed` (entries were reverted but the ledger or manifest rewrite failed), `cleanup_failed` (an orphan sweep failed mid-way), `cargo_cache_patch_kept` (see above), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`, `vendor_pypi_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated). A pass skipped at the lock (`lock_held` | `lock_io`) has no `gc` and no events, only a `gc_skipped` warning whose detail ends with the code. Human mode prints `GC: skipped: .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one). A drift keep is an entry the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so its `verified` reverts may over-promise what a wet run will actually reclaim. +`scan --prune` opts into garbage collection. When set, `scan` removes manifest entries for packages no longer present in the crawl, then deletes orphan blob files, and every obsolete diff and package archive, from `.socket/`. A blob is an orphan when no patch left in the manifest references it: the afterHash and beforeHash blobs of every remaining patch are kept, the same retention policy `repair` uses (the beforeHash blobs are an offline rollback's only restore data, and `repair` downloads afterHash blobs only). Diff and legacy package archives are never kept, even for a remaining patch: nothing reads them any more. Off by default (v3.0) so a temporary uninstall doesn't silently destroy manifest state. Under `--global`/`--global-prefix` the crawl covers only the global installs while the manifest is still `--cwd`'s, so the prune also keeps every entry the `--cwd` project accounts for (its installed packages and its lockfile-listed ones, exactly what the project's own `--prune` keeps): a global prune removes only entries neither scope has. Only entries whose ecosystem this run actually crawled are eligible: a `pkg:/` with no crawler in this build (a newer CLI's ecosystem in the committed manifest) is exempt — the crawl never looked for them, so their absence is not evidence of removal (same fail-safe as the `--ecosystems` filter, which narrows the query but never the prune's installed set). A Cargo entry is also exempt while its agent-mode copy is still patched: the project crawl looks up only the crates `Cargo.lock` resolves, but a crate the lock bumped or dropped keeps its patched copy in the machine-wide `$CARGO_HOME/registry/src` cache (nothing deletes it), and the entry holds the only blobs that can restore that copy. The wet pass keeps it with a `cargo_cache_patch_kept` warning naming the `socket-patch rollback ` that restores the copy and drops the entry; the `--dry-run` preview reports no event (and no warning) for it. The pass also reconciles vendored state (runs FIRST, under ONE apply-lock acquisition shared with the manifest prune — lock contention skips the whole pass without failing the scan; `--lock-timeout` is honored and a lock I/O error is reported rather than swallowed; the existence gate — a manifest file OR a vendor ledger file, both cheap stats; an emptied ledger is deleted on save, so its presence is its content proxy — runs BEFORE the lock, so a bare project never gets a `.socket/`; in the vendored scan arms the pass runs AFTER the vendor step): (a) ledger entries still tracked by a manifest record (manifest-mode entries written by standalone `vendor`) whose patch is gone from the manifest are reverted — `detached` entries (every `scan`/`get --mode vendored` entry, v5.0) have no manifest record to lose and are exempt from this leg; (b) EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted and any manifest entry it still had dropped (v5.0: the check is about the lockfile, not the manifest, so embedded-record entries are no longer exempt; a missing or undeterminable lockfile keeps the entry, fail-safe); and (c) orphan `.socket/vendor//` dirs with no ledger entry are swept. The prune never deletes a zero-patch `.socket/manifest.json` (its `{"patches": {}}` + `setup` block stay). Under `--json` (v5.0, MAJOR) the pass is recorded into the scan envelope: each pruned manifest entry is a `removed` event with `details.manifest: true`, each reverted vendored entry a `removed` event (`errorCode: "vendor_reverted"`, `details.mode: "vendored"`), each drift-kept entry a `skipped` / `vendor_revert_kept` event and each entry whose revert failed a `skipped` / `vendor_revert_failed` event (a GC revert failure never fails the run); a `--dry-run` preview reports the removals as `verified`. The artifact sweep is the envelope's `gc` (`summary.bytesFreed` mirrors it) and the orphan vendor dirs swept are the top-level `removedVendorOrphanDirs` count. The pass's warnings join the top-level `warnings[]`: `vendor_state_write_failed` (entries were reverted but the ledger rewrite failed), `manifest_write_failed` (the pruned manifest could not be written: no entry was pruned and no blob, diff or package archive was swept, since the manifest on disk still references them), `cleanup_failed` (an orphan sweep failed mid-way, or `.socket/` or one of its `blobs/`, `diffs/`, `packages/` directories is a symlink, which is never swept), `cargo_cache_patch_kept` (see above), and the reinstall advisories of the vendored reverts (`vendor_bun_reinstall_required`, `vendor_vlt_reinstall_required`, `vendor_pypi_reinstall_required`; a revert's other warnings, such as `vendor_lock_entry_removed` or a drift keep's, are not repeated). A pass skipped at the lock (`lock_held` | `lock_io`) has no `gc` and no events, only a `gc_skipped` warning whose detail ends with the code. Human mode prints `GC: skipped: .`, one `GC: .` line per warning, and `GC: failed to revert N vendored entries: …` (singular for one). A drift keep is an entry the revert deliberately preserved (`vendor_artifact_kept` — undo the drift and re-run `vendor --revert` to finish); the preview cannot see drift (backends return before the wiring replay on dry runs), so its `verified` reverts may over-promise what a wet run will actually reclaim. `scan` queries the patch API in `--batch-size` chunks. Authenticated runs POST `/v0/orgs/{slug}/patches/batch`; token-less runs POST `{proxy}/patch/batch` on the public proxy and degrade to per-package `GET /patch/by-package/:purl` requests in two cases: the deployed proxy predates the batch endpoint (legacy proxies answer the POST with their `400 "Unsupported endpoint"` catch-all), or the all-or-nothing batch validation rejects the chunk (e.g. a crawled PURL type the server doesn't recognize, such as `pkg:jsr/…` — the per-package path tolerates those individually, preserving the pre-batch scan semantics). Rate limits and over-capacity 503s surface instead of silently degrading. -**Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, so the legacy per-package path still skips that package), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries fails its whole batch query, so every package in that batch goes unchecked and is reported through the batch-failure channel above (an unresolvable PURL, or a "not configured" 503, is still skipped individually). Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. +**"No patches" needs an answer.** `scan` and `get` report that there is nothing to patch only when the patch service said so; a response that is not the service's answer is a failed query, never an empty result. The service answers an error in its own JSON shape — the API's `{"error": {"message": …}}`, the public proxy's `{"error": …, "message": …}` — and that shape is how its answer is told apart from another host's (a mistyped `--proxy-url` / `--api-url`, a captive portal, a forward proxy or WAF answering in its place): + +* A search (`by-package`, `by-cve`, `by-ghsa`) or the batch query is answered by a `200` with the expected JSON (an empty list is "no patches"), or by a `404` carrying the service's error body. A `404` whose body is not the service's (an HTML page, another service's JSON), a `200` whose body is not that JSON, and a `4xx` the service did not write are errors that name the host that answered and the setting to check. So is a `404` with no body at all from a host nothing has vouched for: every `get` search and every per-package lookup of the token-less path below. (Only a scan's per-package detail queries, which run after the service has answered that scan's batch query, still read a bodiless `404` as "no records".) The error: `API request failed with status 404: did not answer like the Socket patch API () — check --proxy-url / SOCKET_PROXY_URL` (on the authenticated route: `— check --api-url / SOCKET_API_URL and the organization slug '' (--org / SOCKET_ORG_SLUG)`). +* The service saying that a route is not there — its `404` `Organization not found` (a mistyped `--org` / `SOCKET_ORG_SLUG`) or `API route not found`, the proxy's `400 "Unsupported endpoint"` — is the same error on every route, the patch view and the hosted package references included: `API request failed with status 404: Organization not found (answered by ) — check …`. A patch view's `404` with the service's `Patch not found`, or with no body, is still "no such patch" (`get `: `notFound`, exit 0). +* The token-less per-package path degrades only on an answer that leaves the question open: the proxy's `400 "Unsupported endpoint"` catch-all, its `503 "Patch API is not configured"`, a `404` / `405` for the POST (a CDN layer without the route), or a `400` in the service's error shape (the chunk's validation). A `400` some other host wrote fails the batch at once, with no per-package requests. On that path a package counts as checked when the service returned its patch list or refused the PURL itself (its own `400`: it serves nothing for that PURL). Every other outcome — a transport error, a `5xx`, a `429` / `503` still there after the retries below, the `503 "Patch API is not configured"`, a response that is not the service's — leaves the package **unanswered**. +* When no package of a batch is answered, the batch fails with the first such error, through the batch-failure channel below (a one-batch scan: `Error: The API query failed: `, `error.code: api_batch_failed`, exit 1, and no "No patches available" line). +* When some are answered, the scan goes on with those and reports the rest: the human `Warning: of packages could not be checked for patches (, … [, more]): ` line on stderr (printed under `--silent` too, since it is why the run exits 1) and, under `--json`, a `warnings[]` entry `{code: "patch_lookup_failed", detail: }` plus one `failed` event per unanswered package (`errorCode: "patch_lookup_failed"`, `error`: its own error). The run is a partial failure in every mode: `status: "partialFailure"`, exit 1. A scan that found no patch but could not check every package (unanswered packages, or a failed batch) prints `No patches found for the packages that could be checked; the others were not checked (see the warning above).` instead of `No patches available for installed packages.` + +Pinned by `tests/scan/scan_proxy_fallback_e2e.rs`, `tests/cli/api_client_errors_e2e.rs` (`get`) and the core crate's `tests/proxy_batch_e2e.rs`. + +**Throttling: bounded retry, then a reported failure.** Every patch-API JSON call (the batch query, the per-package patch lists, patch views and VEX record fetches, hosted package references) retries an HTTP `429` or `503` answer up to 3 times (`SOCKET_API_MAX_RETRIES=`, `0`-`10`; `0` = no retry). The wait honors `Retry-After` (delta-seconds or HTTP-date); a `Retry-After` over 30 s is not waited out — the answer is final at once — and one under the jittered first backoff step (`0`, a past date) waits that step instead. Without one it backs off 0.5 s / 1 s / 2 s (each step up to 8 s, with jitter in its upper half). All retries in one run share a 60 s wall-clock window that opens with the run's first retry: a retry whose wait would end after it closes is refused and the answer is final. Parallel requests wait in parallel, so each still gets its retries while the run adds at most about 60 s. Nothing else is retried (401/403 still drive the proxy fallback on the first answer; the public proxy's permanent `503 "Patch API is not configured"` is never retried on any path — the batch query still degrades to the per-package path at once, and a per-package lookup or patch view answering it is the same non-throttle failure it always was, which on the legacy per-package path leaves that package unanswered, see above), and a retried answer folds exactly where the first attempt's would have, so output is identical to an unthrottled run's. A request still throttled after that is a failure in the channel its siblings use: a failed batch is the human `Warning: API batch of failed: ` line and, under `--json`, a run-level `warnings[]` entry `{code: "api_batch_failed", detail: "API batch of failed: "}` (additive; `status` stays `success`, exit 0 — the other batches' packages are reported); a failed per-package patch-list query in the agent / hosted / vendored flows is the human `Warning: could not fetch details for : ` line and, under `--json`, `{code: "patch_details_failed", detail: "could not fetch details for : "}`. When every batch (or every patch-list query) fails, the existing all-failed error envelope and exit 1 apply. The error names the exhausted retry: `Rate limit exceeded (HTTP 429, gave up after 3 retries). Please try again later.` / `API request failed with status 503: (gave up after 3 retries)` (or `(Retry-After s exceeds the 30 s retry cap)` / `(the run's 60 s retry window has closed)`); with retries off it is the pre-retry text. On the token-less legacy per-package proxy path (a proxy without `POST /patch/batch`), a package still throttled (429 / over-capacity 503) after its retries is an unanswered package (see "No patches" needs an answer above): the batch's answered packages are used and it is reported as `patch_lookup_failed` (`partialFailure`, exit 1); when it leaves no package of its batch answered, the batch fails through the batch-failure channel above. Only a PURL the service itself refuses (its own `400`) is skipped individually. Pinned by `tests/scan_api_retry_e2e.rs` and the core crate's `tests/api_retry_e2e.rs`. **Lockfile supplement (v3.4)**: `scan` discovery is no longer limited to installed trees. The project's lockfiles (`package-lock.json`/`npm-shrinkwrap.json`, `pnpm-lock.yaml` v9, `yarn.lock` classic + berry, `bun.lock`, `vlt-lock.json` (registry nodes, Socket-hosted pins included; vendored `file` nodes are left to the vendor ledger), `Cargo.lock`, `go.sum`, `composer.lock`, `Gemfile.lock`, `uv.lock`/`poetry.lock`/pinned `requirements.txt`) are inventoried and dependencies with NO installed copy join discovery — counts, the API lookup, the table (flagged ` [NOT INSTALLED]`, plus a stderr note), and the prune "scanned" set (a wiped node_modules no longer prunes lockfile-listed entries). JSON gains a top-level `lockfileOnlyPackages` count and an additive `notInstalled: true` on matching `packages[]` entries. Agent mode partitions lockfile-only patches out BEFORE download (calm `skipped`/`package_not_installed` events — never an error exit, never a manifest write); vendored mode passes them through to the vendor engine's server download. Vendored-ledger entries likewise stay discoverable on a fresh clone (the committed artifact is the dependency). Global scans (`--global`) get no supplement. **Rush monorepos** (no root lockfile, `rush.json` present): the npm-lock inventory falls back to the Rush source-of-truth locks — `common/config/rush/pnpm-lock.yaml` plus every `common/config/subspaces/*/pnpm-lock.yaml` (`read_dir`-sorted, repo-relative paths preserved) — so a Rush repo's dependencies still join discovery. **Plug'n'Play layouts are an explicit refusal, not an empty inventory**: a `.pnp.*` loader means the npm packages are structurally unreachable in EVERY mode (under yarn PnP the installed-tree crawl is empty too — no `node_modules/`), so `scan` surfaces an additive top-level `warnings[]` array (`{code, detail}` objects, omitted when empty) carrying `yarn_pnp_unsupported` (same code as apply's refusal; remedy `yarn patch `) or `pnpm_pnp_unsupported` (pnpm's `node-linker=pnp` twin; pnpm remedies), plus a stderr `Warning: …` line on the human path. Exit code and `status` are deliberately unchanged (exit 0 / `success` — the same posture as hosted refusals, which exit 0 with no `applied` event); the warning is the machine-readable signal that nothing was checked. Pinned by `tests/e2e_safety_yarn_pnp.rs`. **A Bundler lock socket-patch cannot read is likewise a warning, not an empty inventory**: when the project holds gem files but bundler loads no `Gemfile.lock` / `gems.locked` socket-patch reads (`BUNDLE_GEMFILE` or bundler 4's `BUNDLE_LOCKFILE` naming another file, or a `Gemfile` + `gems.rb` twin, whose loaded pair depends on the bundler major that runs), `warnings[]` carries the additive `gem_lock_unsupported` (detail names the setting or the twin) and its lockfile-only gems are not discovered. Same exit-0 / `success` posture. @@ -346,7 +372,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Rewritten file ownership**: a hosted or vendored rewrite of a project file that already exists (its lockfiles, package manifests and `.npmrc`, and their `rollback` / `remove` / `vendor --revert` restores; not socket-patch's own `.socket/` records) replaces it with a staged file that keeps the old file's permission bits and, best-effort, its owner and group, like npm's write-file-atomic. A root run (a Docker bind mount, `sudo`) leaves a user's `poetry.lock` owned by that user. An unprivileged run can't give a file to another user; it keeps the old group when it belongs to that group, and otherwise the file takes the runner's owner and group, with no warning. -**Vendored group commit (v5.0)**: `vendor`, `scan --mode vendored` and `get --mode vendored` capture every lockfile / manifest / config edit and every ledger save of the run in memory (reads inside the run see them) and commit them ONCE after the per-package loop — including the packages that succeeded in a run where others failed, so a completed run leaves the same files per-package commits would. Captured: every file under the project root outside `.socket/`, plus `.socket/vendor/state.json` and `.socket/vendor/redirect-state.json`; artifacts are written directly (see the durability note). A multi-file commit goes through a roll-forward journal, `.socket/vendor/.commit-journal.json` (the new bytes of every changed file, plus the bytes each replaces and their sha256; deleted once the commit completes). **Crash semantics**: before the journal is durable, nothing is committed — the lockfiles and ledgers are the pre-run ones and the run's artifacts are unreferenced orphans; after it, the next command that takes the apply lock replays the journal before reading anything (files already at their new bytes are left alone), so a locked command never observes a half-committed run. A journal that matches neither side of some file (edited by hand since the crash) is renamed to `.socket/vendor/.commit-journal.set-aside-.json` (keeping every file's pre-commit bytes) and stderr says what was done (`Warning: an interrupted vendored run's commit could not be finished as written: …`): the edited files are never written over; when they all still carry the commit's own lines the rest of the commit is finished around them, when none of them does the files the crash had already replaced are put back to their pre-commit bytes, and otherwise nothing is applied. A journal that is unreadable, names a path outside the lockfiles and ledgers, or would write through a symbolic link is set aside with nothing applied. A replay that fails on I/O keeps the journal and fails the lock acquire (`lock_io`, naming the journal). Read-only commands that take no lock (`vex`, `list`) may observe the interrupted state until then. A re-vendor under a newer uuid removes the replaced uuid's dir only after the commit (its `vendor_stale_artifact_removed` event follows the run's per-package events), and a golang takeover removes the `.socket/go-patches/` copy only after the commit that repoints `go.mod`. A commit never renames over a symbolic link: when a changed file is a symlink, the whole commit is refused before anything is written, with the top-level error `redirect_symlinked_file_unsupported` (exit 1; a `--dry-run` predicts it with a `vendor_would_refuse_symlinked_file` advisory) — the artifact dirs the run's loop added under `.socket/vendor/` are removed, and every package it vendored is reported as a `failed` event with that code instead of `applied` (its per-package `skipped` advisories from the same run, such as `vendor_prebuilt_downloaded`, are dropped). A commit write failure is the top-level error `vendor_commit_failed` (exit 1; the pre-run lockfiles and ledger stay, the artifact dirs the run added are removed and its vendored packages are `failed` events with that code, the same way — unless putting back the files already replaced failed too, in which case the journal is kept, the next locked command finishes the commit, and the per-package events stand). `repair`, `vendor --revert` and `rollback` still save per entry. +**Vendored group commit (v5.0)**: `vendor`, `scan --mode vendored` and `get --mode vendored` capture every lockfile / manifest / config edit and every ledger save of the run in memory (reads inside the run see them) and commit them ONCE after the per-package loop — including the packages that succeeded in a run where others failed, so a completed run leaves the same files per-package commits would. Captured: every file under the project root outside `.socket/` and outside any git directory (`.socket` and `.git` are matched under every spelling a filesystem folds onto them, such as `.GIT` or `git~1`; no backend writes into a git directory), plus `.socket/vendor/state.json` and `.socket/vendor/redirect-state.json`; artifacts are written directly (see the durability note). A multi-file commit goes through a roll-forward journal, `.socket/vendor/.commit-journal.json` (the new bytes of every changed file, plus the bytes each replaces and their sha256; deleted once the commit completes). **Crash semantics**: before the journal is durable, nothing is committed — the lockfiles and ledgers are the pre-run ones and the run's artifacts are unreferenced orphans; after it, the next command that takes the apply lock replays the journal before reading anything (files already at their new bytes are left alone), so a locked command never observes a half-committed run. **A replay is never silent**: the command that performed it prints `Warning: an interrupted vendored run's commit was finished from its journal: rolled forward ; …` on stderr (in every output mode, `--silent` and `--json` included), and under `--json` the envelope it goes on to print carries the same sentence as a `vendor_commit_recovered` entry in `warnings[]` (an error envelope printed before the command reached its own work may omit the entry; stderr always has the line). **`--dry-run` replays nothing**: a dry run that takes the lock and finds a journal exits 1 with the top-level error `vendor_commit_pending` (the message names the journal and the files it covers), having written nothing; the journal and every file it covers stay as the interruption left them, and the same command without `--dry-run` (or any other lock-taking command) finishes the commit. A journal that matches neither side of some file (edited by hand since the crash) is renamed to `.socket/vendor/.commit-journal.set-aside-.json` (keeping every file's pre-commit bytes) and stderr says what was done (`Warning: an interrupted vendored run's commit could not be finished as written: …`; under `--json` also a `vendor_commit_set_aside` entry in `warnings[]`): the edited files are never written over; when they all still carry the commit's own lines the rest of the commit is finished around them, when none of them does the files the crash had already replaced are put back to their pre-commit bytes, and otherwise nothing is applied. The journal is a file in the working tree, so a checkout can carry one that no crash wrote (a pull request can commit it); a replay therefore writes only what a commit could have journaled. A journal that is unreadable, names a path outside the captured set (an absolute path, a path with a `..` component, an artifact or any other uncaptured file under `.socket/`, anything inside a `.git` directory), or would write through a symbolic link is set aside with nothing applied, not even its other entries. A replay that fails on I/O keeps the journal and fails the lock acquire (`lock_io`, naming the journal). Read-only commands that take no lock (`vex`, `list`, and the lock-free `--dry-run` previews of `scan` and `get`) may observe the interrupted state until then. A re-vendor under a newer uuid removes the replaced uuid's dir only after the commit (its `vendor_stale_artifact_removed` event follows the run's per-package events), and a golang takeover removes the `.socket/go-patches/` copy only after the commit that repoints `go.mod`. A commit never renames over a symbolic link: when a changed file is a symlink, the whole commit is refused before anything is written, with the top-level error `redirect_symlinked_file_unsupported` (exit 1; a `--dry-run` predicts it with a `vendor_would_refuse_symlinked_file` advisory) — the artifact dirs the run's loop added under `.socket/vendor/` are removed, and every package it vendored is reported as a `failed` event with that code instead of `applied` (its per-package `skipped` advisories from the same run, such as `vendor_prebuilt_downloaded`, are dropped). A commit write failure is the top-level error `vendor_commit_failed` (exit 1; the pre-run lockfiles and ledger stay, the artifact dirs the run added are removed and its vendored packages are `failed` events with that code, the same way — unless putting back the files already replaced failed too, in which case the journal is kept, the next locked command finishes the commit, and the per-package events stand). `repair`, `vendor --revert` and `rollback` still save per entry. `scan --sync` is sugar for `--mode agent --prune` — the canonical single-flag agent-mode bot invocation. `scan --json --sync` discovers, applies, and reconciles state in one pass. @@ -354,24 +380,85 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Path-scoped scans (`scan [PATHS]...`, v5.0)**: what a PATH means depends on the mode. -* **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. +**A bare hosted or vendored scan covers the checkout (additive/MINOR).** With no PATH, `scan` in hosted or vendored mode (the inferred default included) scans `--cwd` and every other project directory below it in the same git checkout, exactly as if each had been named as a PATH: its own lockfiles, ledgers, `.socket/` and inferred mode, one `== ==` heading each (`== . ==` for `--cwd` itself), the worst exit code, one `--max-new-patches` budget. A project directory is one the in-memory engine would detect as a root for the same tree: a directory holding a lockfile of a supported npm-family, PyPI, Cargo, RubyGems, Go or Composer format (a manifest alone never makes one; Maven, Gradle, sbt and NuGet projects are scanned only as `--cwd` or a named PATH), found by a walk that honours git's ignore rules (`.gitignore`, the repository's and the user's excludes; a `.ignore` file, which hides files from search tools and not from git, is not read), never follows a link, never enters `node_modules`, `.git`, `.socket`, `.yarn` or `vendor`, and never enters another repository: a directory below `--cwd` that holds a `.git` entry (a submodule, a nested clone) is left to a run of its own, since this checkout does not track its files and its own `socket.yml` was not read. A workspace member whose only lock is one the workspace root's run reads is not a project of its own: a Cargo workspace member's `Cargo.lock`, and a pnpm workspace member's `pnpm-lock.yaml` the root accounts for. Discovered directories are subject to the `socket.yml` path filters and the built-in `test/`, `tests/`, `fixtures/`, `__fixtures__/` and `testdata/` ignores, as glob-matched PATHs are; the ones left out are counted in one stderr note (`Note: N project directories under ignored paths ... were not scanned; name a directory to scan it.`), and naming a directory scans it. `--cwd` itself is scanned whenever it is a project, with or without a lockfile, as before. Nothing is searched for, and the run is the single-directory scan it always was, when: `--cwd` is not inside a git checkout (a scan from a home directory never goes looking for projects), a PATH is given, the scope is `--global`/`--global-prefix`, or the mode is agent. `--json` and `--vex` report on one project directory, so with either a bare scan scans `--cwd` alone and adds the run-level `projects_not_scanned` warning naming the project directories it found and left (`N project directories below this one were not scanned (a, b): --json takes one project directory. Run one scan per directory, e.g. ...`); exit code and status are unchanged. A tree with more than 500,000 entries is searched only that far, with a stderr warning. + +**What a bare scan patched at and below `--cwd` is listed and rolled back from `--cwd` (additive/MINOR).** A bare scan covers `--cwd` and the project directories below it, so `list` and a bare `rollback` cover the project at `--cwd` and the patched project directories below it. A bare `rollback` reaches exactly as far as a bare scan from the same directory: from an agent-mode project (whose scan covers that project alone) it rolls back that project alone and names the directories below in a `projects_not_rolled_back` warning. `vex`, `apply`, `repair`, `remove` and `rollback ` act on the one project at `--cwd`, and name the patched directories below when `--cwd` holds nothing for them. The directories below are the ones a bare scan covers (`commands/projects_below.rs`: the same root detection and walk, the same `socket.yml` path filters and built-in `test/`, `tests/`, `fixtures/`, `__fixtures__/` and `testdata/` ignores, applied before any file of a directory is read; `SOCKET_NO_SOCKET_YML` bypasses the file here as `--no-socket-yml` does for the scan), kept when they hold patch state: a manifest with records, a vendor ledger with entries, or hosted pins in their lockfiles (a store that cannot be read counts, so the directory's own run reports it). Nothing is searched for when `--cwd` is not inside a git checkout, the scope is `--global`/`--global-prefix`, or `--manifest-path` is not the default (it names the one project the run is about). With no patched directory below, every command is, byte for byte, the single-project run it always was. + +* **`list`**, above patched directories, lists the project at `--cwd` first when it holds patches itself, under the heading scan gives `--cwd` (`== . ==`), then each directory below as its own `list` would, under its heading (`== ==`, relative to `--cwd`), then one total line: `5 patches in this directory and 2 project directories below it.`, or `4 patches in 3 project directories below this one.` when `--cwd` holds none (its `No patches in this project.` line is then not printed). The exit code is the worst of the listings: a directory whose manifest cannot be read fails as it does on its own (exit 1) and is named in a final stderr line (`Error: 1 project directory could not be listed: svc`; `--cwd` is named `.`). `--json` stays one document: the envelope for `--cwd` exactly as it is on its own (its `events`, `summary`, `warnings`, `error`), with the additive top-level `projects[]` beside it, one element per directory below: `path` (relative to `--cwd`), then the fields of the envelope that directory's own `list --json` prints (`status`, `events`, `summary`, `warnings`, `error`). `projects` is absent when no directory below holds patches. When a directory below could not be listed, the top-level `status` is `partialFailure` (it goes with the exit code) and that directory's element carries its `error`. +* **`rollback` with no target**, above patched directories, rolls back the project at `--cwd` when it holds anything to undo, then each directory below, each as if it were `--cwd` (its own `apply.lock`, stores and lockfiles), under the same headings (`== . ==` first), and no other directory; one that fails does not stop the ones after it. The exit code is the worst of the runs. A run with no failure ends with `Rolled back this directory and 2 project directories below it.` or `Rolled back 3 project directories below this one.` (`Would roll back ...` under `--dry-run`, which previews every directory and writes nothing); otherwise a final stderr line names the failed ones (`Error: Rollback failed in 1 of 3 project directories: svc`; `--cwd` is named `.`). The confirmation is asked once for the whole run and names the directories (`Roll back every Socket patch in this directory and 2 project directories below it (api, web)? [Y/n]`, or `... in 3 project directories below this one (api, svc, web)? [Y/n]`), under the usual rules: answered yes with `--yes`, `--json` or no terminal, not asked for `--dry-run` or `--preserve-state`. `--json` reports on one project directory, the one at `--cwd`, and never changes another. When `--cwd` holds something to undo, it is rolled back alone, as before, and its envelope carries the run-level `projects_not_rolled_back` warning naming the directories it left (`N project directories below this one hold Socket patches and were not rolled back (a, b): --json takes one project directory. Run one rollback per directory, e.g. ...`), exit code and status unchanged: the dual of a `--json` scan's `projects_not_scanned`. When `--cwd` holds nothing to undo, the run fails as it always did (`manifest_not_found`, exit 1, nothing written) and its envelope carries the `patched_projects_below` warning naming the directories below and the command for each, whether there is one or several. +* **`rollback `, `remove`, `vex`, `repair` and `apply`** do not fan out, and their messages and exit codes are unchanged. `rollback ` selects among the patches at `--cwd`, as before. When its target matches none there (`manifest_not_found`, `patch_not_found`), and for the others when `--cwd` holds nothing for them, they print below their own message a stderr `Note:` naming the directories and the command that reaches each (`socket-patch vex --cwd api`: `--cwd` as the run was given it, and the run's own target, `--dry-run`, `--check`, `--preserve-state`, `--skip-rollback` or `--download-only`, so a preview is told to preview), muted by `--silent`; under `--json` the envelope they already print carries the same text as the run-level `patched_projects_below` warning. Each names only the directories it has work in: `vex` every patched directory; `remove` and `rollback ` the ones holding a patch the target matches (when none does, they say so and point at `list`); `repair` the ones with manifest records or vendor-ledger entries (when every directory is hosted, it says there is nothing local to repair); `apply` and `apply --check` the ones with manifest records, and nothing otherwise: `apply` reads no lockfile for this, and `apply --silent` does not search at all. A message spells out the first 10 directories and commands and counts the rest. +* A search that cannot be completed is reported, never guessed around: a `socket.yml` that cannot be honored stops it (`Warning: the project directories below this one were not searched for Socket patches: ...`), and a tree of more than 500,000 entries is searched only that far (`Warning: this directory tree is too large ...`); `list --json` carries either as a `projects_below_not_searched` warning. + +* **Hosted and vendored mode (bare `scan` included) — project directories** (`run_project_dirs`). Each PATH is a directory, or a glob (`*?[`) matching directories, relative to `--cwd`; the set is sorted and deduplicated, and each directory is scanned on its own exactly as if it were `--cwd` (its own lockfiles, ledgers and `.socket/`). With more than one directory, each run is headed `== ==` on stdout (unless `--silent`; `--cwd` itself is headed `== . ==`), and the exit code is the worst of the runs. Usage errors (exit 2, stderr only, before any scan): a PATH that is not a directory (`` `X` is not a directory``), a glob matching no directory (`` `X` matches no directory``), an invalid glob, and `--json` with more than one directory (`--json takes one project directory (N given); run one scan per directory`), so stdout stays one document. Likewise `--vex` with more than one directory (`--vex takes one project directory (N given); run one scan per directory`): the one output path would be overwritten by each run. * **Agent mode (and a mode-less `--prune`/`--global` report) — installed-path globs** scoping DISCOVERY at the **purl level**: a package is in scope iff ANY of its crawled installed copies sits under a matching path (every copy, enumerated the way `rollback`'s path targets enumerate them: any package link in a `node_modules` root, such as a pnpm workspace member's link into the root `.pnpm` store (or npm's linked `.store`), alias links included (`packages/a/node_modules/lp` for `"lp": "npm:left-pad@1.3.0"`, matched by its target's own `package.json` name@version), and a nested member copy count, not just the one copy the crawl records per purl), and a selected package is then handled with ALL its copies (scoping selects which packages are considered, never which copies). Glob semantics (shared with `rollback`'s path targets, `src/path_scope.rs`): Unix-shell globs with `require_literal_separator` — `*`/`?` never cross a `/`, `**` spans directories; a pattern matching any **ancestor** directory of the copy path also matches, so a bare `scan packages/foo` scopes the whole subtree without `/**`; relative patterns match against the copy path relativized to `--cwd`, absolute patterns against the absolute path (the ONLY way to reach paths outside the project tree, e.g. `--global` stores — a relative pattern never matches outside `--cwd`); leading `./` and trailing `/` are normalized away, matching is purely textual (no filesystem access or symlink resolution), case-sensitive except on Windows (whose filesystems are not); an unparseable or empty pattern is a usage error (exit 2). **The prune universe is never narrowed**: the path filter is applied strictly AFTER the `scanned_purls` capture (and after `--ecosystems`), so `scan PATHS --prune` prunes exactly what an unscoped `scan --prune` would — a scoped scan can never treat an out-of-scope package as uninstalled (the same fail-safe as the `--ecosystems` filter). Lockfile-only and vendor-ledger supplement records have no installed path and are EXCLUDED from a path-scoped scan, surfaced as one run-level `path_scope_excluded_supplements` warning carrying the count. A scope matching nothing is a normal empty scan — exit 0, zero packages, **no GC** (the zero-package early return fires before any GC). `PATHS` combine with `--mode agent`/`--sync`/`--prune`/`--global`. Every scan JSON envelope past the argument checks (success, zero-package, all-batches-failed) carries a `paths` key echoing the patterns verbatim (empty array when unscoped; a hosted/vendored per-directory run is unscoped, so it is `[]`). One-sentence duality rule: **a target that selects nothing is an error on `rollback` (exit 1) and an empty scan on an agent-mode `scan` (exit 0)**. `scan --mode vendored` swaps the in-place apply for the vendor pipeline: discover → download the selected patch records **into memory** (no manifest write) → vendor every selected dependency via the same engine as the `vendor` command (under the same lock). Vendored mode is **manifest-free (v5.0)**: `.socket/manifest.json` is never written or read by a vendored run; each ledger entry carries `detached: true` plus an embedded copy of the patch record (`record`) as its verification source, and the run's footprint is `.socket/vendor/**` only. The vendor step's scope is what discovery selected — the former "whole manifest is vendored" re-vendor on an empty discovery is retired (`repair` verifies and redownloads committed vendored state; `scan --prune` reconciles ledger entries whose dependency left the lockfile). The vendor-ledger discovery supplement (the fresh-clone rule: a ledger entry with no installed copy stays discoverable because its committed artifact IS the dependency) holds only while the lockfile still resolves through that artifact: an entry the lockfile in-use probe (the one `--prune` reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted `--prune` reports it through the run-level `vendor_ledger_entry_unwired` warning; a `--prune` run reverts it in its GC and exits 0. That GC runs even when the crawl found no packages, as its vendored half alone (the manifest prune stays skipped there). A package the ledger holds at an older patch uuid is still **re-vendored automatically** when discovery selects the newer patch (its old uuid dir is removed — `vendor_stale_artifact_removed`) — unless the patch service has no prebuilt artifact for the newer patch yet (or at all): the package then keeps the older patch and is a `skipped` `vendor_prebuilt_pending` / `vendor_prebuilt_unavailable` event, not a failure, in every ecosystem (#954, #1235); same-uuid re-runs reuse the embedded record, skip the patch-view fetch, and are `already_vendored` skips. **Legacy manifest-mode entries**: when a vendored run vendors a purl that also has a `.socket/manifest.json` record (a project vendored by a pre-5.0 binary, or by standalone `vendor` from an agent-mode manifest), that manifest record is dropped in the same run — the ledger becomes the owner (migration write); an emptied manifest is left as `{"patches": {}}`, never deleted. The migration is reported through the run-level `warnings[]` (stderr in human mode), never as a run error: `vendor_manifest_record_migrated` (`N manifest records moved to the vendor ledger (vendored mode is manifest-free): `) or `vendor_manifest_migration_failed` (the manifest or the ledger could not be read or rewritten; the legacy records were left in place) — so a corrupt `.socket/manifest.json` no longer fails a vendored run (standalone `vendor`, the one manifest-driven writer, still fails closed on it). With `--prune`, GC runs **after** the vendor step (the step never reads the manifest, and running the sweep last lets it reclaim what the run itself orphaned — a migrated legacy record's blobs, a superseded uuid dir). Under `--json` (v5.0, MAJOR) the download phase and the vendor engine record into the ONE scan envelope (no nested `download` / `vendor` objects): a fetched patch is a `downloaded` event (the patch metadata in `details`, plus `details.oldUuid` when the ledger holds the purl at another uuid — the re-vendor the vendor step then performs — whose human `[fetch]` line reads ` (replacing )`), a refused or failed one a `failed` event with its code, a patch the ledger already holds at the selected uuid no download event at all (reused, no fetch); then the vendor engine's events (`applied`, `rebuilt`, `skipped`, `failed`, `removed`, its uncounted advisories) follow with their warnings and sidecars. Every one of these events carries `details.mode: "vendored"`. Patch blobs are held in memory (see "Patch sources stay in memory" under the vendor contract). `--dry-run` previews per patch: `verified` (to vendor; `oldUuid` on a re-vendor), `skipped` / `already_vendored` (in sync), or `skipped` with the refusal code for an npm purl the wet run's Bun preflight (see the `get --mode vendored` bullet below) would refuse, or whose hosted pnpm pin the takeover would be refused on (see "Takeover reconciliation"), or for a PyPI purl the PyPI vendored backend's wiring guards would refuse before building anything (the project routing and lock guards a standalone `vendor --dry-run` reports, such as `pypi_no_requirements` or `pypi_uv_no_sources`, on the project lane and the PEP 723 script lane alike, #891; a PyPI purl with a live hosted pin is not judged, since the wet run restores its upstream entry before those guards run) — without network downloads or disk writes; the preview never flips status or exit (the human path — `scan` and `get` alike, through one shared printer — prints the headline `[dry-run] Would download and vendor N patches. No changes made.`, or, when some patches are in sync or would be refused, `[dry-run] Would download and vendor K of N patches (A already vendored, R would be refused). No changes made.` with only the non-zero clauses (#1850), then a `[skip] (already vendored)` line on stderr for each in-sync patch — the wet run's line — and `[would-refuse] (): ` lines, all behind the `--silent` gate). Interactive mode prompts "Download and vendor N patches?" (singular for one). **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place (a re-run restores the zip line `go mod tidy` drops while no package of the module is loaded, so a later import still builds), never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); with a `go.work` in effect the build graph is the workspace's (every `use` member's go.mod, go.sum and `go.work.sum`), so a member raising the module refuses the pin with `redirect_golang_version_mismatch`, and a `go.work` that overrides, ignores or rejects the replace refuses it with `redirect_golang_workspace_conflict` before anything is written. Run in the directory holding the `go.work`, a module the workspace shares (no root `go.mod`, or the root module is a `use` member and another member lists it or `go.work` already holds the Socket replace) is pinned in `go.work` and its two sum lines go into `go.work.sum`, the members' files untouched; a member's own `replace` of it, which the go.work pin would override, refuses it (`redirect_golang_workspace_conflict`); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A vendored Composer package is taken over the same way: its `composer.lock` entry is restored to the recorded pre-vendor entry, its ledger entry and committed copy are removed, and it is redirected in the same commit, so the lock matches a direct hosted scan and `rollback` can restore it from packagist. Without the revert the rewriter edited the vendored entry in place and kept its patch-uuid `dist.reference`, which `rollback` refused. A vendored Composer entry the takeover cannot revert (its vendored ledger entry is missing) is left vendored with `redirect_composer_vendored_entry`, never repointed in place. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the entry down to the patch's version; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is a `skipped` event whose `errorCode` is the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `verified` events and warnings are the wet run's `applied` events and warnings. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_pypi_no_lockfile` (pypi: a granted patch reached the PyPI rewriters but the project has no root `requirements.txt`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, PEP 751 / PEP 723 lock or Hatch configuration — once per run, beside each patch's `redirect_unconfirmed` row; #638), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the per-dep `source` block to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_cargo_directory_source` (#455: a cargo config replaces crates.io with an on-disk source through its `[source.crates-io] replace-with` chain — a directory source, as a `cargo vendor` project has, or a `cargo local-registry` — so the project builds every crates.io crate offline from it and a Socket registry pin would break `cargo build --frozen --offline`. The configs read are the ones cargo reads for a build in the scanned directory: the project's own `.cargo/config*`, every parent directory's (a monorepo root) and `$CARGO_HOME`'s. Every cargo dependency is skipped with nothing written, one warning each naming the crate and the config that set the replacement, and the detail points at agent mode, which patches the copy cargo builds in place. A replacement with a registry mirror is not an offline source and is still redirected). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place (a re-run restores the zip line `go mod tidy` drops while no package of the module is loaded, so a later import still builds), never because the patch-server origin or leftover go.sum lines appear somewhere. Gradle is confirmed the same way (`confirmed_gradle_uuids`): only when the final files hold the owned script, the index row, the live apply line in every build's settings file and the suffixed version in every lock entry of the GA (see [Gradle builds](#gradle-builds-v50)). A golang module that go.mod does not require and go.sum has no zip (`h1:` of the module tree) line for at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written); with a `go.work` in effect the build graph is the workspace's (every `use` member's go.mod, go.sum and `go.work.sum`), so a member raising the module refuses the pin with `redirect_golang_version_mismatch`, and a `go.work` that overrides, ignores or rejects the replace refuses it with `redirect_golang_workspace_conflict` before anything is written. Run in the directory holding the `go.work`, a module the workspace shares (no root `go.mod`, or the root module is a `use` member and another member lists it or `go.work` already holds the Socket replace) is pinned in `go.work` and its two sum lines go into `go.work.sum`, the members' files untouched; a member's own `replace` of it, which the go.work pin would override, refuses it (`redirect_golang_workspace_conflict`); a `/go.mod`-only line doesn't count, since go writes one for every version MVS reads (#509). In a go < 1.17 graph, where `require` holds only lower bounds, the build selects the highest of the required version and go.sum's zip-line versions, and a patch for a lower version warns `redirect_golang_version_mismatch` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). A vendored PyPI package (requirements.txt, Poetry, Pipenv, uv, Hatch, PDM, pylock) is taken over the same way: its vendored wiring is restored to the recorded registry entry, its ledger entry and wheel are removed, and it is redirected in the same commit. The Python rewriters treat any non-registry source as user-authored, so without the revert they refused socket-patch's own vendored source and left the project vendored. A vendored Composer package is taken over the same way: its `composer.lock` entry is restored to the recorded pre-vendor entry, its ledger entry and committed copy are removed, and it is redirected in the same commit, so the lock matches a direct hosted scan and `rollback` can restore it from packagist. Without the revert the rewriter edited the vendored entry in place and kept its patch-uuid `dist.reference`, which `rollback` refused. A vendored Composer entry the takeover cannot revert (its vendored ledger entry is missing) is left vendored with `redirect_composer_vendored_entry`, never repointed in place. A takeover revert that leaves vendored wiring in place is refused with `redirect_vendored_revert_failed`. That covers a drift-skipped record (`vendor_lock_entry_drifted`) and a reverted file that still references the artifact (`vendor_revert_residual_reference`). The ledger entry and artifact are kept, and the package stays vendored and skipped. `--dry-run` predicts the same refusal from the same signals instead of previewing `redirect_would_revert_vendored`. The hosted requirements.txt rewriter only rewrites an existing pin in the root `requirements.txt`, so a vendored requirements.txt package whose wiring is a pin in a `-r` include or a `(transitive)` line vendored mode appended is kept vendored, wet and `--dry-run` alike, with `redirect_requirements_takeover_unreachable` (a top-level `warnings[]` entry, and the `errorCode` of the package's `skipped` event). Its wiring, ledger entry and wheel are kept, so it stays vendored and patched (exit 0). The uv and Poetry rewriters are covered the same way: a vendored uv package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (earlier releases pinned such an entry down to the patch's version, where vendored uv now refuses the patch with `pypi_uv_lock_package_missing`; the revert brings the lock's own version back, and hosted mode only pins the version the lock resolves) is kept vendored with `redirect_uv_takeover_version_unreachable`, and a vendored Poetry package on a Poetry 0.x lock (which hosted mode refuses outright) is kept vendored with `redirect_poetry_lock_unsupported`. **Staged takeover (v5.0)**: the takeover is atomic. Each purl's vendored revert is staged in memory (the run's group commit, the same journaled commit vendored mode uses), the hosted rewrite plans against the reverted project, and a staged purl the rewrite does not pin is retracted: every staged revert is undone, that purl keeps its vendored wiring, ledger entry and artifact byte-identical, and the rest are staged and rewritten again. A retracted purl is a `skipped` event whose `errorCode` is the cause: its existing skip reason (unavailable wheel metadata, …), the rewriter warning that names the package (`redirect_yarn_berry_missing_checksum`, `redirect_pypi_platform_wheel`, …), `redirect_requirements_takeover_unreachable`, the rewrite's lock-level refusal (`redirect_yarn_berry_mixed_line_endings`, `redirect_bun_lock_unsupported`, a Gradle planner refusal, …) or `redirect_takeover_not_pinned`; that warning is reported in `warnings[]`, followed by `redirect_takeover_kept_vendored` naming the package. Exit 0: the package stays vendored and patched, never unpatched in both modes. The reverts, the hosted pins and the vendored ledger then reach the disk in one commit, and the reverted artifacts are deleted only after it: a refusal or write failure before the commit writes nothing, a failed commit puts back the files it replaced, and a commit interrupted after its journal was written is finished by the next command that takes the apply lock. `--dry-run` runs the same steps and drops the staged state instead of committing, so its `verified` events and warnings are the wet run's `applied` events and warnings. A hosted run with no takeover stages its writes the same way but commits them without the journal (it writes nothing under `.socket/`): a file that fails to be replaced puts back the ones already replaced, so a failed run leaves no lock half-redirected. Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` before its first wet write (the staged takeover reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON prints the envelope with `status: "error"` and `error: {code: "lock_held" | "lock_io", message}` (no `redirect` payload: nothing was rewritten). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). Under `--json` (v5.0, MAJOR) the outcome is recorded into the scan envelope: one event per selected patch, sorted by purl, each with `details.mode: "hosted"` — `applied` for a confirmed pin (`verified` under `--dry-run`), `skipped` with the skip reason as `errorCode` (and its detail as `reason`), or `skipped` / `redirect_unconfirmed` for a patch that was granted but no lockfile entry pinning it could be rewritten (the human output's `Not hosted : …` line) — plus a `redirect: {mode: "hosted", rewrittenFiles}` payload (the files rewritten, or that would be). A `skipped` event does not change `status` or the exit code (the hosted exit policy is an open decision, #704). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_pypi_no_lockfile` (pypi: a granted patch reached the PyPI rewriters but the project has no root `requirements.txt`, `uv.lock`, `poetry.lock`, `pdm.lock`, `Pipfile.lock`, PEP 751 / PEP 723 lock or Hatch configuration — once per run, beside each patch's `redirect_unconfirmed` row; #638), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_gem_bundle_lockfile_unsupported` (gem: bundler 4's custom lockfile — the `BUNDLE_LOCKFILE` environment variable, else `BUNDLE_LOCKFILE:` in the bundler app config, else in the global config — names a lock other than the loaded pair's own `Gemfile.lock` / `gems.locked`, so no gem is redirected or attested rather than pinning a lock bundler ignores), `redirect_gem_twin_manifest_ambiguous` (gem: a `Gemfile` + `gems.rb` twin under default discovery; bundler 1.x loads the `Gemfile` and bundler ≥ 2 loads `gems.rb`, and a lock's `BUNDLED WITH` records which bundler wrote it, not which one installs it, so neither pair is wired or attested — remove the unused spelling or set `BUNDLE_GEMFILE` to the one in use), `redirect_gem_mirror_overrides_source` (gem: Bundler's all-source, exact patch-source or patch-hostname mirror can route the gem's Socket `source:` to an unpatched upstream gem. Intake reads the app config (`BUNDLE_APP_CONFIG`, where a set-but-empty value selects `/config`, honoring `BUNDLE_IGNORE_CONFIG`) and all `BUNDLE_MIRROR__...` variables visible to the scan; app config overrides the environment per encoded key, then `mirror.all` takes precedence over exact source, which takes precedence over hostname. URI matching follows Bundler's whole-URI case folding, default-port/trailing-slash normalization and single slash key alias, not URL prefixes. An exact-source fallback-timeout key without a mirror URL shadows the hostname mirror and fetches that source directly; a configured URL is conservatively refused even if a timeout could bypass an unreachable mirror at install time. Like `redirect_gem_bundle_gemfile_unsupported`, the gate leaves the Gemfile pair byte-identical and confirms no gem redirect. On an embedded `scan --vex`, rediscovered older hosted gem pins may attest only from verified installed bytes: a missing tree is not excused by the lockfile, and `--vex-no-verify` omits those hosted gems with `mirror_overrides_source` rather than trusting their intercepted source. Agent/vendored evidence, unrelated ecosystems and standalone VEX behavior are unchanged. Details identify the setting form and its app/environment origin without printing mirror values or source URLs, which may contain credentials. Remove the applicable all/source/hostname setting (including any slash alias) from that origin and reuse its existing mirror URL under `mirror.https://rubygems.org` to clear the refusal; an environment setting must be unset in the scan/install environment. User-global Bundler config and mirrors set only in a later install environment are not inspected; keep those mirrors scoped to the upstream source too), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds; a leading UTF-8 BOM, which dotnet reads past, is not corrupt and is kept on rewrite), `redirect_nuget_lock_other_version` (the lock also resolves the patched id at another version in some target framework: the exact-id `packageSourceMapping` would route that framework to a feed that serves only the patched version, so the dep is skipped with nothing written; only lock entries at the patched version are ever re-pinned, and `resolved` is never rewritten), `redirect_nuget_mapping_set_aside` (v5.0 #462: another source's `packageSourceMapping` also named the patched id exactly — Visual Studio's mapping UI writes such lists — which ties with the Socket source so NuGet would take the package from whichever feed answers first; that pattern, or its whole `` when it was the only one, is commented out in a `` comment while the patch is wired, and `remove` / `rollback` put it back byte-exact), `redirect_nuget_mapping_conflict` (such a pattern sits in markup that cannot go in a comment; the dep is skipped, nothing written). v5.0 #354: when the rewriter creates the `packageSourceMapping`, its `*` catch-all also names the sources NuGet merges in from the user config and every parent directory's config (honoring ``), since NuGet drops every source no pattern names; a fresh config only seeds `nuget.org` when those inherited configs have it (a parent that cleared it for a mirror keeps that choice); when an inherited config already has a `packageSourceMapping` (NuGet merges those too), no catch-all is written at all — only the Socket pattern — so a source the parent restricts is never widened; the exclusivity set-aside exempts only the source the run wires, so a `socket-patch-*` lookalike key or a stale uuid naming the id is set aside too, `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip), `redirect_cargo_dep_overridden` (the crate does not resolve to crates.io because the user overrides it — its one `Cargo.lock` block carries a git / other-registry `source`, as a `[patch]` git or registry override in the root manifest or any cargo config leaves it, or has no `source` while the root `Cargo.toml` / project `.cargo/config*` holds a `[patch]` path entry for it; with no `Cargo.lock`, the same `[patch.crates-io]` / `[patch."https://github.com/rust-lang/crates.io-index"]` entry for the crate, by key or `package = "…"`, in the root `Cargo.toml` or the project `.cargo/config*` — transactional skip, nothing rewritten: a Socket pin would silently replace the user's fork and the then-unused `[patch]` entry breaks `cargo --locked`). Also additive: `redirect_cargo_directory_source` (#455: a cargo config replaces crates.io with an on-disk source through its `[source.crates-io] replace-with` chain — a directory source, as a `cargo vendor` project has, or a `cargo local-registry` — so the project builds every crates.io crate offline from it and a Socket registry pin would break `cargo build --frozen --offline`. The configs read are the ones cargo reads for a build in the scanned directory: the project's own `.cargo/config*`, every parent directory's (a monorepo root) and `$CARGO_HOME`'s. Every cargo dependency is skipped with nothing written, one warning each naming the crate and the config that set the replacement, and the detail points at agent mode, which patches the copy cargo builds in place. A replacement with a registry mirror is not an offline source and is still redirected). Also additive: `redirect_gem_version_not_locked` (gem: no `GEM` section of the lock lists the crawled `name (version)`, for example a version another project installed into the shared gem home; the gem is skipped with nothing written, so the user's declared constraint and the locked version are never overwritten), `redirect_gem_no_lockfile` (gem: the project has a `Gemfile` / `gems.rb` but no `Gemfile.lock` / `gems.locked`, so nothing records which version it resolves and the crawled version may be another project's copy in the shared gem home; the gem is skipped with nothing written, never pinned over the user's constraint or appended as a new dependency, and the detail asks for `bundle lock` (or `bundle install`) and a re-run). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). The root `package.json`, which the rewrite re-renders to add `resolutions`, gets the same gate: a mixed one is refused untouched with the same code — the decision vendored mode takes with `vendor_yarn_berry_mixed_line_endings`, from the same shared berry gate set. This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover of a yarn-berry entry is checked against these project gates (mixed `yarn.lock` / `package.json` line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) on the project as it is before any revert, because the revert re-renders `package.json` in its majority ending — wet and `--dry-run` alike. A refused purl keeps its vendored wiring, ledger entry and artifact byte-identical, is skipped with the gate's code (reported in `warnings[]`, followed by `redirect_takeover_kept_vendored`) and is never announced as `redirect_takeover_reverted_vendored`. **Attribution gate (v5.0).** A hosted run never leaves wiring that lockfile discovery calls contested: before the rewrite writes any lockfile, the same discovery `vex`, `list`, `rollback`, `remove` and `vendor` read runs over the project as the rewrite would leave it. A candidate whose pin discovery reads but cannot attribute to one package version (a requirements `-r` include resolving the same version from the registry beside a rewired `Pipfile.lock`, #567; a Maven pin in a ``, #260) is left out of the rewrite and reported as a `skipped` event with `errorCode` `redirect_unattributable` (nothing written for it; exit code unchanged). Unchanged: a pin in a file discovery does not read (a pre-2.6 bundler `Gemfile`, locked by the next `bundle install`) keeps the rewriter's verdict, and so does a deliberate partial redirect the run already reports (a bundled or `bun patch`-ed copy left on the registry, a yarn `npm:` alias entry left on the registry with `redirect_yarn_classic_alias_skipped` / `redirect_yarn_berry_alias_skipped` while the package's direct entry is pinned, a dep withheld from the vlt rewrite while a sibling lock takes it), and so does a vendored→hosted takeover: its vendored wiring is reverted in the run's staged overlay before the rewrite plans, and it is redirected when the rewriters pin it (otherwise it is retracted and stays vendored), in a `--dry-run` preview the same. A pin whose own pnpm or yarn lock also installs an unpatched copy of the same `name@version` (a pnpm `file:` directory or tarball dependency; a yarn `file:` / URL / berry `portal:` / `link:` dependency declared under another name) is written too, with a `redirect_copy_unpatched` warning naming that entry, and is not attested. A lockless NuGet / Cargo pin (an exclusive Socket source mapping without `packages.lock.json`, a Cargo registry pin without `Cargo.lock`) is still written, with a `redirect_pin_lockless` warning: no lockfile records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it until the lockfile exists (whether such pins should be written at all is an open decision). The rollout's recorded view uses the same discovery: a uuid a file merely mentions (a stale `package.json` field, an inactive `pdm.lock`, a comment) is not a pin and does not count as already patched. +**Grant gate (additive).** A reference grant is the patch API's answer, and its fields are written into files the project commits. Each granted reference is therefore checked once, before anything is planned for it (no takeover, no artifact fetch, no rewriter sees one that fails): the patch uuid is a canonical lowercase uuid; the artifact URL and a registry override's index URL are `https` URLs of plain URL characters (no whitespace or control character, no quote, backslash, `$`, `{`, `}`, `<`, `>`, `&`, `#`, `;`, `|` or `^`; cargo's `sparse+` prefix is kept); `sha1` is 40 hex digits, `sha256` 64, `sha512` exactly one `sha512-` token, the Go hashes `h1:` hashes, the yarn berry checksum `/`, the cargo and gem checksums 64 hex digits; the package's name and version, and every identifier of a registry override, are in their ecosystem's coordinate grammar (letters, digits and that ecosystem's punctuation; Maven coordinates as the JVM backends accept them). A Maven grant's sha256 digests may also be spelled `sha256-…` / `sha256:…`: the pom, Gradle and sbt planners read those, and a digest that is still not 64 hex digits writes no checksum pin (`redirect_maven_trusted_checksums_invalid_hash`). An absent or empty optional field is not checked. A grant that fails is refused for that package only: a `skipped` event whose `errorCode` is `redirect_grant_malformed` and whose `reason` names the field (never its value), the same code and detail in `warnings[]`, nothing written for it, the rest of the run and the exit code unchanged. A plain-`http` artifact or index URL is refused the same way with `redirect_grant_insecure_url` (every later install would send the grant token in clear text), unless its origin is the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin (scheme, host and port) or a loopback address (`localhost`, `127.0.0.0/8`, `::1`). The in-memory engine applies the same gate; it has no configured origin. + The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; the hosted redirect is a `sparse+` registry, which cargo loads only from **1.68**, so `rust-toolchain.toml` / `rust-toolchain` are read too (never edited) and a project whose declared floor — the lowest `rust-version` of the root and the member manifests read here (a `rust-version.workspace = true` inheritor reads `[workspace.package]`), else the channel rustup picks from the root (the legacy `rust-toolchain` over `rust-toolchain.toml`; a dated `nightly-`/`beta-YYYY-MM-DD` maps to its release train) — is below 1.68 is refused `redirect_cargo_old_cargo` per crate (nothing written, an earlier run's pins left in place with the `rollback` that drops them named; `--mode vendored` builds on cargo 1.41+), while an undeclared or rolling-channel floor still redirects (toolchain files in ancestor directories are not read); **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json` (plus, v5.0 #353/#514, every lock a project under the root restores into: a member project's `packages.lock.json`, a per-project `packages..lock.json`, a literal `NuGetLockFilePath` — each pinned with the root config; a `NuGetLockFilePath` it cannot evaluate warns `redirect_nuget_lock_path_unresolved` and a project tree it cannot list warns `redirect_nuget_lock_unreadable`, both skipping the nuget redirect with nothing written), `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files), and the sbt build files (`socket-patch.sbt`, `socket-patch-vendor.sbt`, `build.sbt`, `project/build.properties`, `.sbtopts`, `.jvmopts`; `build.sbt.lock` and the Mill / scala-cli build files `build.mill`, `build.mill.yaml`, `build.sc`, `.mill-version`, `project.scala` for their presence only) — read, never edited; `socket-patch.sbt` is the only sbt file hosted mode writes (see **Hosted sbt** below). **npm-family flavor coverage**: package-lock / npm-shrinkwrap (a package the project patches itself with npm ≥ 12.1's native `npm patch` — a root `patchedDependencies` key, or the lock entry's `patched` record — is left on its registry entry in every npm lock, `redirect_npm_patched_dependency_skipped`), pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic (a yarn 2+ install migrates a v1 `yarn.lock` and drops its pins, so a run whose v1 lock carries a hosted pin warns `redirect_yarn_classic_berry_migration_risk` — the hosted twin of the vendored `yarn_classic_berry_migration_risk` — unless the root `package.json`, read as advisory input, declares `"packageManager": "yarn@1…"`), **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found` (or `redirect_bun_non_registry_entry_skipped` when the only same-version entry is a user URL / `file:` tarball, #497), a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when the `repo-state.json` beside a rewritten lock exists (`common/config/rush/repo-state.json` for the common lock, `common/config/subspaces//repo-state.json` for a subspace lock; the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives), and `redirect_pnpm_trust_lockfile` carries the Rush pnpm >=11 install remedy (see the trust paragraph above). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`; a grant sha256 that is not 64 hex digits writes no checksum line, `redirect_maven_trusted_checksums_invalid_hash`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a reactor root (a `` / `` declaration, a profile's included, or a Maven 4 root that lists none but whose subprojects Maven discovers: model version not `4.0.0`, `pom` packaging, a direct subdirectory holding a `pom.xml`, and `maven.project.discoverSubprojects` not turned off in `.mvn/maven.config`; each such subdirectory's `pom.xml` is read for this, never edited) is refused whole with `redirect_maven_multimodule_unsupported`: `pom.xml` and `.mvn/` are left untouched and the dep is not counted as redirected (a Gradle build beside it is still planned by the Gradle rewriter, but a dep in a root with a `pom.xml` is confirmed only when the pom pins it too), since a module's own literal `` would shadow a root pin (use `--mode vendored`; `vex` omits a hosted pin in a reactor root as `vex_maven_reactor_root`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), a `` variant (sources, tests, a native build) left at its version (`redirect_maven_classifier_unsupported`; the grant serves the main jar only, and the dep is skipped, with nothing written, when a variant other than `sources` / `javadoc`, live or inside ``, may resolve any version but the suffixed pin — the patched release, another one, a `${property}` a profile moves, or no version in the pom once its classifier-keyed `` (a profile's too) is consulted — since `vex` would never attest that pin: `vex_maven_classifier_unpatched`), a versioned declaration inside `` (a literal or a `${property}`) left as-is (`redirect_maven_profile_dependency_unpatched`): when any version it may resolve — its own, or what its own profile's or any combination of profiles' `` make of it, read as `vex` reads it — is not the suffixed pin, the dep is refused with nothing written, since it overrides the pin whenever that profile is active and `vex` would never attest it (a profile `` entry only when management governs the pin, i.e. no live direct declaration carries a version); a versionless one takes the pin, and the dep is also skipped when a profile holds its only declaration, a `${property}` groupId / artifactId resolved from the pom's own `` before matching (one the pom does not define, which may be the patched GA and whose own version would then win, refuses the dep with nothing written: `redirect_maven_coordinate_unresolved`), and a `${property}` groupId / artifactId some profile's `` (or several profiles' together) redefine so that the declaration a pin would go into names another package, or another declaration names the GA's main jar at another version (or one of its executable classifier copies, a `test-jar` included), refuses the dep with nothing written too (`redirect_maven_profile_coordinates_unpatched`): `vex` would never attest that pin (`vex_maven_profile_unpatched`), and a pom not readable as one left untouched (`redirect_maven_pom_unreadable`). Matches and anchors skip comments, CDATA, ``, ``, ``, `` and `` (the scope `vex` reads), and an existing self-closed `` / `` is expanded in place, never duplicated; an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). **Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=`, `--=` or `<<=`), `redirect_sbt_onload_assignment` (a build source reassigns `onLoad`, in any scope, with `:=`, `~=`, `--=` or `<<=` without keeping its previous value — a `:=` reading `(Global / onLoad).value`, or a `~=` that composes with `andThen` / `compose`, keeps it — which drops the generated file's load-time verifier and resolver order), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` / `onLoad` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_onload_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`, and the suffixed copy sbt downloaded into `.socket/sbt-hosted` is its only basis). Where the build provably does not use the pinned bytes, that copy proves nothing and the patch is omitted (`vex_sbt_pin_bypassed`): a build source reassigns `dependencyOverrides` (`:=`, `~=`, `--=`; replacing the generated override) or reassigns `onLoad` without keeping its previous value (dropping the installer that holds the load-time verifier and resolver order), whether or not any evidence exists, or a resolution record dated after the generated files and every build source shows the GA's base version (the override shadowed), or the pinned version from outside the pin repository with bytes that are not pinned (judged exactly as the hosted rewriter re-checks a pin, `redirect_sbt_override_shadowed` / `redirect_sbt_resolved_elsewhere`). A record older than the wiring (a project or cross build not re-resolved since) proves nothing, and current records that verify the pin always keep it. A `resolvers` reassignment or `sbt.override.build.repos=true` alone is no bypass: the override still forces the Socket-only version and the load-time verifier checks its bytes, so the build runs the pinned bytes or fails (which is why an `onLoad` reassignment dropping that verifier is one). Stale or partial evidence, project definitions that cannot be read statically and a `build.sbt.lock` only withhold the lockfile basis. A file under a generated name without socket-patch's header is read as the root build definition sbt loads it as. **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". -**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project, which takes the project-local remedy; the `gem env` homes count only when Bundler uses system gems, i.e. no deployment store under `vendor/bundle`, and the first settings tier (app config, environment, global config) that sets `path`, `path.system` or `disable_shared_gems` doesn't set a non-empty `path` without `path.system: true` or `disable_shared_gems: false`, and, when no tier sets any of those, no truthy `deployment` (the first tier that sets it wins; it makes `vendor/bundle` the path), since with such a `path` `bundle install` fetches non-default gems into it and never reuses a system copy; the `.bundle` default that `default_install_uses_path` (Bundler 2.x) or `simulate_version 5` (Bundler 4.x) selects depends on the Bundler that runs, so those flags keep the `gem env` homes judged) for each confirmed gem redirect and judges the materialization by the archive RubyGems installed it from, `/cache/.gem`: an archive whose sha256 is a patched artifact's (the grant's `gemChecksumSha256`, else its tarball sha256) is already patched and never warns, any other readable archive is stale, and a missing or unreadable one is never evidence by itself. When there is no readable archive (a pruned `cache/`, `gem cleanup`) or no patched sha to compare it with, and no agent-mode record (below), the scan fetches that patch's record (`…/patches/view/`, for that purl only) and judges the dir by its `afterHash` map; if that fetch fails too, the dir is unjudged: no warning, but the same run's `--vex` withholds the purl (`stale_install_unverified`) instead of assuming it patched. Otherwise hosted mode fetches no patch record: the warning re-fires on every re-scan until the stale materialization is gone. An agent-mode manifest record of the same patch judges the dir by its `afterHash` file map instead (an in-place patch keeps the upstream archive), so an agent→hosted migration stays quiet; when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — bytes actually read that are not the patched state — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir (under the project root, compared on absolute paths so the default `--cwd .` counts, or under the project's own refused `.bundle/config` path) gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). Standalone `vex` and `apply --check` judge gem copies by the same rule (#1098): when Bundler doesn't use system gems, a copy in a `gem env` home is not one the project loads and is not verified, unless it is a default gem (its spec under `specifications/default/`), which Bundler loads from the system home under any path. The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. +**Gem hosted wiring**: for a gem the manifest (`Gemfile` / `gems.rb`) declares, hosted mode appends ONE option to the user's own declaration, `, source: ""`, after its last argument, and changes nothing else of it: version requirement(s), quote style, other options, trailing comment, position, enclosing `group` / `platforms` / `install_if` block and indentation stay byte for byte. This covers a parenthesized call (the option goes inside the parentheses), a declaration continued over several lines (after the last argument, on its line), several version requirements, `%w` / symbol option values, a trailing `if` / `unless` / `while` / `until` / `rescue` modifier (the option goes before the modifier) and a declaration followed by another statement after a `;`. For a gem the manifest does not declare (a transitive dependency: the lock's `DEPENDENCIES` does not list it) hosted mode adds exactly one line at the end of the manifest, `gem "", "", source: "", require: false # added by socket-patch (transitive dependency)`, after one blank line, in the file's own line ending and keeping whether the file ends on one. `require: false` keeps `Bundler.require` from loading a gem it did not load before the line existed; the marker comment is what tells the unwind the whole line is socket-patch's. **The lock always follows** (the pair is what the Bundler that wrote the lock writes for that manifest, so frozen / deployment installs accept it as committed): on a lock written by Bundler ≥ 2.2 the gem's spec moves to a `GEM` section of its own whose `remote:` is the index URL (sections ordered as Bundler orders them); on a lock written by Bundler 1.17–2.1 the index URL joins the `remote:` lines of the one merged `GEM` section, in the order Bundler derives from the manifest's sources; the `DEPENDENCIES` entry keeps the requirement the manifest declares and gains `!` (a transitive gem gets ` (= )!`); and where the lock has a `CHECKSUMS` section the gem's entry is set to the patched artifact's sha256. A lock with no `CHECKSUMS` section (Bundler < 2.6) is converged the same way and pins the gem by source alone; the run reports that ONCE, naming the gems, as `redirect_gem_no_checksums_section`. A lock does not record which Bundler installs it, only which one wrote it (`BUNDLED WITH`), so the pair is converged for that one; when it is the merged layout of Bundler < 2.2, which later Bundlers deprecate and from 2.5 on refuse under frozen / deployment mode, the run that writes the lock adds `redirect_gem_merged_lock_layout` naming the Bundler the pair is for. **Never a half-applied pair**: each gem is planned on a copy of the pair and committed only when the planned manifest and lock agree about it (the manifest gives it the index URL as its one source; the lock resolves it from a `GEM` section naming that remote, with a source-pinned `DEPENDENCIES` entry). A gem whose declaration cannot take the option, or whose lock cannot follow, is refused with neither file changed for it, and the warning's detail gives the reason and the way out: `redirect_gem_unrecognized_declaration` (the declaration cannot be edited in place — a heredoc or `do` block among its arguments, code before it on its line, a string spanning lines, a last argument that is not a literal and may be an options hash: it is taken for a version requirement only when the lock shows one requirement per positional argument, every other positional is a single quoted string, and the manifest has no `gemspec` directive, whose own requirement for the gem bundler folds into the lock entry, …), `redirect_gem_source_option` (it already selects a source: a `source:` / `git:` / `github:` / `path:` option or one a `**opts` splat may hold, a declaration inside a `source` / `git` / `github` / `path` block, a `DEPENDENCIES` entry that is already source-pinned, or a lock that resolves the gem from a `GIT` / `PATH` section), `redirect_gem_declared_more_than_once`, `redirect_gem_declaration_not_visible` (a direct dependency declared in an `eval_gemfile`d file, a loop or a gemspec), `redirect_gem_dependency_not_locked` (declared, but the lock's `DEPENDENCIES` does not list it), `redirect_gem_lock_unconvergeable` (no `BUNDLED WITH` section to say which layout the lock's Bundler accepts, or a merged Bundler < 2.2 section whose remotes the manifest's sources do not explain) and `redirect_gem_inconsistent_plan` (the self-check above failed; a bug guard, never expected). A re-scan recognizes its own option (the grant-token and patch-uuid path levels of the URL rotate, so they are matched as wildcards) and refreshes the URL in place, in the manifest and the lock together; a re-scan of an unchanged project writes nothing. A lock reverted or regenerated without its manifest (the option still there, the lock back on the registry) is converged again by the next hosted scan. **Pins written by socket-patch 5.0 / 5.1** used a `source "" do … end` block in the declaration's place and, on a lock with no `CHECKSUMS` section, left the lock alone. That form is never written any more but stays recognized everywhere (re-scan, `list`, `vex`, `rollback` / `remove`, the takeovers): a re-scan keeps the block as it is, refreshes its URL like the option's, and converges a lock that never followed it; only where that lock cannot be converged does the run still warn `redirect_gem_frozen_install`. Human mode prints one line after the summary naming the declarations changed, e.g. ``Added `source:` to 3 gem declarations in Gemfile (puma, rack, and rexml); added 1 line for the transitive dependency nokogiri.`` See `docs/ecosystems.md` ("Ruby gems: hosted mode") for the user-facing description. + +**Gem stale-install guard (the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, and Bundler keeps a gem it already holds whatever source the lock names. So after the rewrite a hosted run looks for the gems the project already has on disk, replaces the project's own stale copies with the patched gem itself, and warns about the rest. The unwind (`rollback`, `remove`, a withdrawn-patch restore) does the same in the other direction. Exit code and `status` never change: a refresh that cannot be done leaves everything as it was and falls back to the warning. + +*The defect.* + +- A gem ALREADY materialized under the project's bundle paths keeps its upstream bytes: the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded). +- `bundle install --force`/`--redownload` and `bundle pristine` re-install from the stale cached `.gem` instead of re-fetching — bundler < 2.6 silently (re-verified 2026-11-10 on 2.5.23: every flavor exits 0 and keeps the upstream bytes), bundler ≥ 2.6 with an exit-37 checksum refusal (re-verified on 4.0.15) that leaves the upstream bytes installed after `--redownload`, and leaves the gem dir DELETED after `bundle pristine`. +- A stale archive in bundler's cache dir (`bundle cache` / `bundle package`) is installed in preference to fetching: on bundler ≥ 2.6 a fresh checkout then fails EVERY install on the pinned checksum (exit 37: plain, `--local`, frozen and deployment alike, re-verified on 4.0.15); on bundler < 2.6 it silently installs the upstream bytes. +- The manual remedy, verified on the same bundlers: remove the installed gem's `specifications` entry and its cached `.gem` (and the stale cache-dir archive), then `bundle install` — Bundler then counts the gem as missing, has no archive to reinstall from, and fetches it from the lock's source. + +*What is probed.* + +- After the rewrite, a hosted run probes the installed-gem discovery paths for each confirmed gem redirect: the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project. +- The `gem env` homes count only when Bundler uses system gems, i.e. no deployment store under `vendor/bundle`, and the first settings tier (app config, environment, global config) that sets `path`, `path.system` or `disable_shared_gems` doesn't set a non-empty `path` without `path.system: true` or `disable_shared_gems: false`, and, when no tier sets any of those, no truthy `deployment` (the first tier that sets it wins; it makes `vendor/bundle` the path), since with such a `path` `bundle install` fetches non-default gems into it and never reuses a system copy. The `.bundle` default that `default_install_uses_path` (Bundler 2.x) or `simulate_version 5` (Bundler 4.x) selects depends on the Bundler that runs, so those flags keep the `gem env` homes judged. +- The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. +- The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. +- With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. + +*How a copy is judged.* + +- An installed dir is judged by the archive RubyGems installed it from, `/cache/.gem`: an archive whose sha256 is a patched artifact's (the grant's `gemChecksumSha256`, else its tarball sha256) is already patched and never warns, any other readable archive is stale, and a missing or unreadable one is never evidence by itself. +- When there is no readable archive (a pruned `cache/`, `gem cleanup`) or no patched sha to compare it with, and no agent-mode record (below), the scan fetches that patch's record (`…/patches/view/`, for that purl only) and judges the dir by its `afterHash` map; if that fetch fails too, the dir is unjudged: no warning, but the same run's `--vex` withholds the purl (`stale_install_unverified`) instead of assuming it patched. Otherwise hosted mode fetches no patch record. +- An agent-mode manifest record of the same patch judges the dir by its `afterHash` file map instead (an in-place patch keeps the upstream archive), so an agent→hosted migration stays quiet. +- When several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet. +- Staleness needs **positive evidence** — bytes actually read that are not the patched state — so missing or unreadable files never mark a copy stale. +- A committed cache-dir archive whose sha256 is readable and differs from the patched artifact's is stale by itself, even with no installed dir at all (a fresh checkout with a committed stale cache). +- Standalone `vex` and `apply --check` judge gem copies by the same rule (#1098): when Bundler doesn't use system gems, a copy in a `gem env` home is not one the project loads and is not verified, unless it is a default gem (its spec under `specifications/default/`), which Bundler loads from the system home under any path. + +*The refresh* (default; `--no-gem-install-refresh`, env `SOCKET_NO_GEM_INSTALL_REFRESH`, turns it off). + +- **What is refreshed**: a stale installed gem whose gem home is the project's own and physically inside the project directory (`vendor/bundle`, a `BUNDLE_PATH` or `GEM_HOME` under the project root, compared on absolute paths so the default `--cwd .` counts) — its gem dir, its `/cache/.gem` and, for a gem that builds a native extension, its `/extensions///` — and a stale archive in bundler's cache dir inside the project. The installed `specifications` entry is kept: the patched gem carries the upstream gemspec byte for byte. After the run, with no further command, `bundle exec` loads the patched code, and `bundle install`, `bundle install --redownload`, `bundle pristine`, and a frozen, deployment or `--local` install all succeed and keep it. +- **What is not, by default**: a gem home outside the project (the system gems, `~/.gem`, an rbenv or asdf home) — another project on the machine may pin the upstream bytes of the same `name-version`, and its `bundle pristine` would then fail on the replaced archive's checksum — and a project path that resolves outside the project directory (a `.bundle/config` `path` or `cache_path` naming another directory, a `vendor/bundle` that is a symlink), because the repository chooses that path. `--gem-refresh-shared` (env `SOCKET_GEM_REFRESH_SHARED`) refreshes both the same way. +- **The only source is the verified archive**: the patched `.gem`, whose sha256 must be one the grant pins (the lock's `CHECKSUMS` entry), checked before any byte of it is used. It is taken from disk when another materialization already holds it, else downloaded from the patch registry (`/gems/.gem`, the URL bundler itself would fetch, then the grant's own artifact URL). No `bundle` or `gem` command is run, and nothing else is contacted. `--offline` uses only an archive already on disk. With no verified archive, nothing is changed. +- **The archive must be that gem**: its gemspec's name, version and platform must be the materialization's, it must agree with the installed gemspec on whether the gem builds extensions, and, when the archive the copy was installed from is still in the home's `cache/`, both must carry the same gemspec. An archive holding a symlink, hardlink or device entry is refused (RubyGems would create it; the refresh never writes one). +- **Nothing is ever missing or half-done**: the new gem dir (and extension dir) is built in a stage dir inside the gem home (`/.socket-patch-refresh/`, removed afterwards) and exchanged with the installed one — one atomic exchange where the platform has it (Linux `renameat2`, macOS `renamex_np`), else two renames — and a step that fails puts the earlier ones back. The home's cached `.gem` is replaced together with the gem dir, never without it (that archive is what the next run judges the copy by), and is not created in a home whose `cache/` was pruned. No file is deleted before its verified replacement is in place. +- **Native extensions**: RubyGems itself installs the verified archive into a stage home with the Ruby that owns the installed copy (found as agent mode finds it: the Ruby whose extension dir for the gem is the installed one; never a Ruby inside the project), compiling the extension from the archive's own sources and build scripts with the build arguments RubyGems recorded for the installed copy, and the gem dir and extension dir are swapped in together. The compiled extension therefore always matches its sources. With no such Ruby, a failed build, or `--no-gem-native-rebuild`, the copy is left exactly as it was, with the warning. +- **Re-verification**: after a refresh the probe runs again, and only its verdict counts: a copy the refresh fixed is treated as patched (for the warnings and for the same run's `--vex`) only once the evidence that called it stale says so. +- **`--dry-run`** writes nothing and downloads no archive: it reports what a wet run would refresh (`redirect_gem_install_would_refresh`) and nothing else, since nothing was rewritten and so nothing is stale yet. +- The refresh runs under the apply lock, like `apply`. + +*What is reported.* + +- Each refreshed materialization is a `redirect_gem_install_refreshed` note in the JSON `warnings[]`; a human run prints one progress line per kind instead (`Replaced the installed copy of 2 gems with the patched build: …`). A replaced cache-dir archive is listed with the files to commit. +- Each materialization that is still stale is a `redirect_gem_stale_install` warning in the JSON `warnings[]`, in three flavors: an installed copy in a SHARED gem home (left alone; the remedy is the project-local bundle path, `bundle config set --local path vendor/bundle && bundle install`, which installs every gem of the project from the lock's sources and leaves the shared home untouched, or `--gem-refresh-shared`; never a delete list); a PROJECT-LOCAL installed copy the refresh did not fix (the reason — the archive could not be downloaded or verified, a native build failed, the path is outside the project, the refresh is turned off — and the files to remove by hand, including the project's stale cache-dir archive when there is one); and a cache-dir archive alone. The warning re-fires on every re-scan until the stale materialization is gone. +- A human run prints ONE warning for all of them: how many gems, which gems in which gem home (or why they were not refreshed), why in one sentence, and the remedy as a command. +- A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). A same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract. + +*The unwind direction.* After a hosted unwind — `rollback` / `remove` of a hosted pin, scan's withdrawn-patch restore, `vendor --revert` of a package vendored over hosted wiring before v5 — put a gem back on its upstream registry entry, its installed copies and archives hold the PATCHED gem, with the same consequences in reverse: the patched code keeps running, and on bundler ≥ 2.6 `bundle pristine` fails on the restored upstream checksum. The unwind applies the same refresh with the upstream gem: the sha256 is the restored lock's `CHECKSUMS` entry, else the rubygems.org compact index; the archive is taken from disk, else downloaded from rubygems.org when the lock resolves the gem from it (a gem from another remote is refreshed only from an archive already on disk). An installed copy is stale when its `/cache/.gem` is readable and is not the upstream archive (ruby-platform gems only: the lock's checksum names that archive). Notes are `upstream_gem_install_refreshed` / `upstream_gem_install_would_refresh`; an installed copy left on the patched build is `upstream_gem_stale_install` (one consolidated warning in a human run), and an archive left in bundler's cache dir keeps its `upstream_gem_stale_cache` warning. A gem whose installed copies were refreshed is not counted in rollback's "keep their patched bytes" note. + +*What never refreshes.* A hosted → vendored takeover (`vendor`, `scan` / `get --mode vendored` over a hosted pin) puts the gem on its upstream entry only on its way to the vendored `path:` source, which is what Bundler loads from then on. The copies Bundler already holds are unused leftovers there, so the takeover changes none of them and downloads no gem for them: resetting the installed copy to the upstream build would cost a registry download in the mode that needs none, and would turn a patched leftover into an unpatched one that `vex` reports against the vendored artifact. Its `upstream_gem_stale_cache` advisory about a patched cache-dir archive is as before. A vendored unwind (`remove`, `rollback`, `vendor --revert`, `scan --prune`, `vendor`'s reconcile of a vendored gem) likewise only names that archive. **Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept exactly as Pipenv wrote them (present or absent: whether Pipenv records `index` depends on its release, the Pipfile spelling and the locking environment, so only the entry itself knows) and `version` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the Python stale-install guard's evidence (the pinned patched wheel, else the patch record; STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`), with the `sync` arguments following the lock, since plain `pipenv sync` installs only `default`: the targeted form re-syncs the categories that pin the package (`--dev` for `develop`, `--categories ""` for a named category) and the `--rm` form re-syncs every non-empty category — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback** (v5.0, upstream restore): each hosted entry gets its registry shape back — `"version": "=="`, the entry's own `index` carried back unchanged (refused unless it — and the Pipfile's explicit `index`, if any — names a PyPI source in `_meta.sources`), and every release file's sha256 from PyPI's JSON API (`SOCKET_PYPI_JSON_API`), sorted by filename as Pipenv records them; an entry that pins another version beside the hosted reference is refused with the `git checkout` remedy (see "Hosted unwind coverage"). A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. @@ -389,7 +476,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm * **Installed-version narrowing** (all modes, `get`'s search path): a CVE/GHSA fan-out returns one patch record per patched VERSION; get keeps only versions present here and emits calm `skipped` events (`errorCode: "package_not_installed"`) for the rest — never an error exit. Presence = installed on disk (qualified-aware resolver) ∪ already tracked in the manifest (record maintenance keeps working on hosts without an installed copy); hosted/vendored modes additionally count lockfile-resolved deps and vendor-ledger purls (mirroring scan's discovery supplements, including their `--global` gate). **Exempt** (no narrowing): UUID identifiers, exact-versioned PURL identifiers (explicit intent), `--save-only` runs (record-only has no installation precondition — the fresh-clone record→vendor flow keeps working), `--all-releases`, and the package-name path (already installed-derived). When EVERY found patch is filtered out, get exits 0 with status **`notInstalled`** (the `skipped` events, plus any warnings) — never `noMatch`, which remains pinned to the package-name path (exact names; near names are only suggested). PnP layouts are surfaced, not misreported: yarn-PnP npm results skip with `errorCode: "yarn_pnp_unsupported"` in every mode; pnpm-PnP skips carry `pnpm_pnp_unsupported` in agent/vendored modes; hosted mode — the refusal's own remedy — keeps ONLY the versions the raw `pnpm-lock.yaml` text actually resolves (boundary-anchored probe over the v5/v6/v9 key spellings, so a large fan-out never requests grants for every version ever patched), labels a JUDGED miss `package_not_installed` exactly like a non-PnP project (the layout blocked nothing — the lock was read and the version isn't resolved), and reserves the layout code for an unreadable lock (no judgment possible). When EVERY narrowed-out result is a PnP refusal, the human terminal names the layout instead of claiming "not installed" and never advises `--all-releases` (which cannot make PnP patchable); the JSON status stays `notInstalled` — consumers dispatch on each `skipped` event's `errorCode`. Hosted mode also runs the per-release VARIANT filter (`filter_to_installed_releases`) on its search path before requesting grants — agent/vendored runs get it inside the download engines — with the same keep-all-plus-warning fallbacks (surfaced as `release_narrowing` entries in `warnings[]`). An ecosystem this binary has no crawler for is likewise never judged: its results are KEPT (absence from a crawl that never looked carries no information — the same fail-safe as scan's prune GC). The human `Found N patches:` listing shows only the patches whose package version survived the narrowing (the narrowing is judged over every result, so an installed package's paid fix a free user cannot download still lists as `[PAID] (no access)`, while skip records and counts cover only accessible patches), sorted by PURL in natural version order (`4.17.2` before `4.17.10`); the narrowed-out ones are summarized on stderr in one line per reason (`Skipped N patches for M package versions not installed here (use --all-releases to include them).`), and `--verbose` adds one `[skip] ()` line per skipped version after that summary, in natural version order. When the candidates hold more patches than were selected and the pick was made without a menu (a paid user's auto-pick, `--yes`, a non-TTY run), a `Selected:` block names the patch (purl, tier, short uuid, advisories) that will be installed before the prompt. Machine output (the prompt count, the JSON envelope) uses the kept set, unchanged. The finer per-release variant narrowing (`filter_to_installed_releases`) is unchanged and still runs inside the download engines (and before an agent-mode `--dry-run` preview, so the preview names only the variants a wet run would fetch). * **Deliberate divergences from scan** (documented, not drift): agent-mode get keeps its `selectionRequired` JSON posture for free multi-patch PURLs (scan and, v5.0, hosted/vendored get auto-pick); get has no `--vex` (an ambient `SOCKET_VEX` is ignored by get's modes), no `--prune`; get does not run scan's pre-vendor baseline annotation; and an all-narrowed-out run exits `notInstalled` without entering the vendor step (heal-after-wipe re-vendoring stays `scan --mode vendored`'s job). Agent-mode `get` honors `--dry-run` too (v5.0): the search and uuid paths classify each selected patch against the manifest (read-only; an unreadable manifest fails closed like the wet run) and stop before the prompt, the download, any `.socket/` write and the apply — human `[would-add]` / `[would-update] … (replacing )` / `[skip] … (already in manifest)` lines then `[dry-run] Would download and apply N patches. No changes made.`; JSON `dryRun: true` with a `verified` event per patch a wet run would record (`oldUuid` on a would-be update), `skipped` / `already_in_manifest` for one already recorded, plus the narrowing `skipped` events; exit 0. -`--dry-run` previews what `apply` / `rollback` / `scan --mode agent` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, top-level `dryRun: true`, `verified` events); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`verified` to vendor, `oldUuid` on a re-vendor, `skipped` / `already_vendored` in sync — plus `skipped` with the refusal code for npm purls the wet run's Bun preflight would refuse (or, see "Takeover reconciliation", whose hosted pnpm pin the takeover would be refused on): an in-sync `already_vendored` entry is exempt, as is a re-vendor whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. +`--dry-run` previews what `apply` / `rollback` / `scan --mode agent` / `repair` / `remove` — and `get` in every mode (hosted/vendored since v3.6, agent since v5.0) — would do without mutating disk. `get --mode hosted --dry-run` flows through the hosted engine's dry-run contract (no lock, no `.socket/`, no lockfile writes, top-level `dryRun: true`, `verified` events); `get --mode vendored --dry-run` emits the same ledger-classification preview as scan's (`verified` to vendor, `oldUuid` on a re-vendor, `skipped` / `already_vendored` in sync — plus `skipped` with the refusal code for npm purls the wet run's Bun preflight would refuse (or, see "Takeover reconciliation", whose hosted pnpm pin the takeover would be refused on): an in-sync `already_vendored` entry is exempt, as is a re-vendor whose `bun.lock` instances are all already local tuples; a purl the lock still resolves from the registry is refused like a fresh one, and the preview stays exit 0 / `status: "success"` with nothing written) before any download, and both skip the confirm prompt (nothing to confirm). In JSON mode, the envelope is populated with would-be actions and counts (`remove --dry-run` skips the confirmation prompt — there is nothing to confirm — and flips its would-be `Removed` events to `Verified` previews, so `summary.removed` stays "entries actually deleted"). `rollback --dry-run` (v5.0) previews every leg — the in-place restore verification, the vendored unwire (`Would revert/unwire vendoring for …`), the hosted upstream restore (every pin is resolved exactly like a wet run — registry lookups included, so a pin the wet run would refuse is previewed as that refusal — the files the restore would write are checked like a wet run's too, so one it could not write is previewed as the `hosted_write_failed` failure, and nothing is flushed to disk), the manifest removals (simulated in memory), and the blob/archive GC — with no writes and no prompt. v5.0 removed the v4 spellings `scan --apply` (use `--mode agent`), `scan --vendor` (use `--mode vendored`), `get --no-apply` (use `--save-only`), and the `download` (use `get`) and `gc` (use `repair`) subcommand aliases, with no deprecation release. Each is now an ordinary clap usage error (exit 2). @@ -532,7 +619,7 @@ Contract details: * **JSON success surface**: `apply` adds a top-level `vex` object to its envelope; `scan` adds a top-level `vex` key to its result. Both carry `{ path, statements, format: "openvex-0.2.0" }`. * `apply`'s no-manifest early exit (the `noManifest` success no-op; v5.0: its human line is `No patch manifest found; nothing to apply.` — it names the missing `.socket/manifest.json`, not the folder, since `.socket/` may legitimately hold vendored state) and `vendor`'s (`No manifest found, nothing to vendor.` — a project with hosted pins ejects instead, v5.0) still generate the document from the lockfiles and the vendor ledger (manifest-less VEX: hosted / vendored checkouts carry no manifest). Nothing referenced anywhere keeps the calm exit 0 (a stale document at the path is removed; `--json` carries any discovery diagnostics in `warnings[]`); any other VEX failure fails the command with exit 1 — including a run whose only candidates are omitted `record_unavailable` (an `--offline` run over a lockfile-wired checkout with no local records), so an ambient `SOCKET_VEX` there fails the install. `--dry-run` skips generation on both, and so does `apply --check` — it stays read-only and offline-safe, leaving the output path untouched. `scan` has no such early exit: with no manifest and nothing wired anywhere its `--vex` fails with `manifest_not_found`. * **Stale-doc removal (v3.5)**: a run that ends in a VEX error removes a recognizably-OpenVEX file (JSON whose `@context` names openvex.dev) already sitting at the output path — a pipeline reusing one path can never ship yesterday's attestation for a now-unpatched tree. Unrelated files at the path are never touched; a mid-write partial that no longer parses as JSON is left for downstream parsers to reject loudly. -* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release; for a pypi package of a project whose governing pdm.lock was written by PDM < 2.11 it names `pdm sync --reinstall`, or removing the installed copy first on PDM 1.x); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). +* **Additive warnings (v3.5)**: `product_not_iri` (the `--product`/`--vex-product` override is neither a `pkg:` purl nor an absolute IRI; honored verbatim, warned), its document-id twin `doc_id_not_iri` (the `--doc-id`/`--vex-doc-id` override has no IRI scheme; honored, warned), `vex_not_verified` (the run used `--no-verify` / `--vex-no-verify` / `SOCKET_VEX_NO_VERIFY`: the detail starts ` of VEX statements were emitted without on-disk verification`, see the provenance markers below) and `vendored_tree_out_of_sync` (a healthy vendored attestation stands on the committed artifact + lock wiring while the PRESENT installed tree hash-mismatches the patched bytes — run the package manager's install (for a project with Hatch environments the detail also names `hatch env remove `, since Hatch keeps an installed release; for a pypi package of a project whose governing pdm.lock was written by PDM < 2.11 it names `pdm sync --reinstall`, or removing the installed copy first on PDM 1.x); the attestation itself is unchanged). Both ride stderr in human mode and `warnings[]` in the standalone `vex --json` envelope. Same channel for `product_multiple_manifests` (auto-detect found several project manifests and names the one it used), `vex_stale_doc_removed` (the stale-doc removal above happened), the manifest-less plan's advisories — `vex_wiring_conflict` (the lockfiles wire a package to different patches: which files, which uuids, how to fix it), `vex_record_superseded` (a recorded patch replaced by the lockfile-wired one), `vex_claim_unwired` (a ledger claim whose patch the lockfiles still mention, but not as wiring), `vex_record_offline` / `vex_record_not_found` / `vex_record_fetch_failed` (why a lockfile-wired patch has no record — the detail behind a `record_unavailable` skip) and `api_auth_fallback` (the public proxy served free patches only: the authenticated API refused the credentials, `get` / `scan`'s warning text; or, v5.0, a token was set with no `--org` / `SOCKET_ORG_SLUG` / socket-cli `defaultOrg` and the org could not be resolved, so the whole run used the proxy — standalone `vex --json` carries it when it fetched records, and `scan --json` / `get --json` report this case in their top-level `warnings[]`) — and, standalone only, `org_looks_like_path` (`-o`/`--org` given a file-shaped value — `-O` is `--output`). The standalone error envelope carries `warnings[]` too. An embedded `--vex` that fails also folds each omitted patch into the host command's `warnings[]` as `vex_omitted` (`: ()` — standalone `vex` lists them as `skipped` events), and `--silent` lists them as `omitted: ()` lines under the error. A corrupt `.socket/vendor/state.json` is no longer degraded with a warning: every form of vex fails with `vendor_ledger_corrupt` (see the error-code table). A malformed pre-v5 `redirect-state.json` is, as of v5.0, only the `redirect_ledger_corrupt` warning (hosted mode keeps no ledger; the file is an optional migration record source). ### VEX provenance markers (contract) @@ -546,6 +633,12 @@ Every VEX statement's impact string records which patch-application mode persist `vendored` and `redirected` are disjoint in practice (the modes conflict); if a PURL somehow appears in both sets, `vendored` wins. +**Unverified statements (`--no-verify`).** `--no-verify` / `--vex-no-verify` (and an ambient `SOCKET_VEX_NO_VERIFY`) skip every on-disk hash check, so a statement emitted under it rests on the patch records and the wiring gates alone — for an agent-mode patch on nothing but the manifest, even when the installed files are unpatched or absent. Such a run is never silent. Each purl it attests ends its impact-statement part with `; not verified on disk`, appended AFTER the marker, so the three marker strings above stay intact as prefixes (`Patched via Socket patch (vendored); not verified on disk`), and the run raises the `vex_not_verified` warning (stderr in human mode, `warnings[]` / `vex.warnings` under `--json`) naming how many statements carry the suffix. The purls an in-run `scan --mode hosted --vex` confirmed are attested from that run's own evidence in either mode and never carry it. A default (verifying) run's document is unchanged: it never contains the suffix. + +**Identifier overrides.** `--product` / `--vex-product` and `--doc-id` / `--vex-doc-id` are emitted with surrounding whitespace trimmed (a blank value means unset). + +**No product to detect.** The product is read from the git `origin` remote, then from the name and version in `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `composer.json`, `pom.xml`, the root's single `.csproj` or its single `.gemspec`. An application with none of these (a Bundler or pip project in a directory that has no git remote) has no product, and the run stops with `product_undetected` (`vex`: exit 2; embedded `--vex`: the host command's exit 1). No product is assumed: a directory name is not an identity (one checkout is `app` in a container and `workspace` in CI, and unrelated projects share those names), so a document issued under it could be read as a statement about another project. The message says what detection read, names any of those files that exists without a usable name and version, and ends with the run's own flag and a value built from the directory name, ready to paste: `… Pass a name that identifies this project, for example: --product pkg:generic/my-app` (`--vex-product …` on an embedded run). A run started in the project directory says `the current directory` instead of `.`. + **Patch hosts (manifest-less VEX).** A hosted lockfile reference counts only when it points at Socket's patch server or the operator's `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin. A hosted URL on any OTHER host — a staging patch server used without `--patch-server-url`, or a look-alike host — is not a hosted pin at all (v5.0: there is no redirect ledger to vouch for it), so `vex` does not attest it, and `list`, `rollback`, `remove`, `vendor` and `repair` do not see it either. Pass `--patch-server-url` for a non-production patch server. A pre-v5 redirect ledger still on disk does not change this: a record whose lockfile or recorded wiring resolves the package from a hosted-shaped URL on an origin this run was not given (or one carrying userinfo) is never attested from the ledger, whatever is installed and even under `--no-verify` / `--vex-no-verify`: `vex` reports it as `redirect_unwired`, with a `vex_claim_unwired` note naming the cause. (A patch that `apply`'s manifest also records is still verified as an agent-mode patch, on that record.) `scan` does not treat such wiring as gone: its takeover and hosted-wiring advisories still see it. ### Manifest-less VEX (lockfile discovery) @@ -559,32 +652,33 @@ Every VEX statement's impact string records which patch-application mode persist 3. Lockfile discovery — the only source of hosted references (v5.0). 4. Hosted records: the ones an in-run `scan --mode hosted --vex` fetched this run, and — for migration only — a pre-v5 `.socket/vendor/redirect-state.json`'s `records` (a malformed one is the `redirect_ledger_corrupt` WARNING, v5.0, and is simply not consulted). Anything else is fetched from the API (see **Record resolution**). -Discovery is read-only, never touches the network, and never fails the run: a malformed file becomes a diagnostic. It reads files at `--cwd`, the root where the vendor ledger is read, and it does so under `--global` / `--global-prefix` as well, because discovery is what gates the ledger entries (below). It reads only root files (no nested workspace-member locks) except where noted, and it reads **every** supported file that is present. There is no precedence chain: the hosted rewriter edits every candidate it finds, so a lock that another lock "shadows" can still carry wiring. Every value is committed, tamperable data, so each one is validated fail-closed: canonical uuid grammar, path-safe coordinates, root-anchored `.socket/vendor/` paths, and the patch-host allowlist. +Discovery is read-only, never touches the network, and never fails the run: a malformed file becomes a diagnostic. It reads files at `--cwd`, the root where the vendor ledger is read, and it does so under `--global` / `--global-prefix` as well, because discovery is what gates the ledger entries (below). It reads only root files (no nested workspace-member locks) except where noted, and it reads **every** supported file that is present. There is no precedence chain: the hosted rewriter edits every candidate it finds, so a lock that another lock "shadows" can still carry wiring. Every value is committed, tamperable data, so each one is validated fail-closed: canonical uuid grammar, path-safe coordinates, root-anchored `.socket/vendor/` paths, and the patch-host allowlist. A vendored path whose artifact name carries a `%` is no reference: npm, yarn and pnpm open the literal file while pip percent-decodes a `file:` reference, so it names no single artifact to verify, and no vendor backend writes one. | Ecosystem | Files read | Hosted reference | Vendored reference | Hosted pin (`integrity_required`) | |---|---|---|---|---| | npm | `package-lock.json` and `npm-shrinkwrap.json` (both when both exist) | `resolved` on the patch host (`packages` in v2/v3; `dependencies` only in v1; `link` entries skipped, and so are `inBundle` / `bundled` entries a dependency bundles (a copy the root project's own `bundleDependencies` covers, and the dependencies npm installs beneath it, has no parent tarball and is rewired like any entry, #1618; a copy beneath a root-bundled package that declares its own bundle stays skipped, since the lock cannot tell which bundle carries it), and so is any entry npm installs from a git, URL or `file:` spec or from a dependency's own `npm-shrinkwrap.json` (beneath a `hasShrinkwrap: true` package, which npm 7–11 install from that shrinkwrap; `redirect_npm_shrinkwrapped_instance_skipped` / `vendor_shrinkwrapped_instance_skipped`), together with every ref for the same `name@version`) | `resolved: file:.socket/vendor/npm//-.tgz` | `integrity`, required | -| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; vendored references are unaffected. pnpm 11+'s env lockfile document (config dependencies, the pinned package manager) is read as its own install tree: its references neither contest nor are contested by another lock, and its entries never contest the project document's references. One of its references is never attested while the project document of the same lock resolves the same `name@version` from elsewhere, because a VEX statement covers the release, not one install tree: a registry entry or a `file:` copy withholds it with `patched_ref_unattributable` (re-running `scan` rewires a registry entry), and a bundled copy omits it like any pnpm bundled copy (`vex_pnpm_bundled_copy`). | -| yarn | `yarn.lock` (classic and berry) | classic `resolved`; berry entry keyed and resolved `@` + root `package.json` `resolutions` `"@npm:": ""` (older releases: `resolution: …::__archiveUrl=`) | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | +| pnpm | `pnpm-lock.yaml` (every `lockfileVersion`); `shrinkwrap.yaml` only when there is no `pnpm-lock.yaml`; with `rush.json`, `common/config/rush/pnpm-lock.yaml` + `common/config/subspaces/*/pnpm-lock.yaml` | `packages:` `resolution.tarball` on the patch host | `file:.socket/vendor/npm/…` tarball + key | `integrity`, required. With pnpm's `lockfile` setting off (#1074, outside Rush) pnpm reads no lock: every hosted reference in the root and member locks keeps no pin, and one `patched_ref_unattributable` per lock names them; a vendored reference is attested only through an override pnpm reads without the lock (`vex_lock_not_read` otherwise, see **pnpm with its lockfile off**). A second `packages:` entry of the wired `name@version` installed from a git remote or a url tarball (`name@git+https://…` with its `version:` line), from a `file:` directory or tarball (a directory whose `package.json` cannot be read, outside the project or missing, may be any version of the package its key names), or, in a lockfileVersion 5 / 6 lock, under another registry key (one per peer set) left on the registry, is an unpatched copy pnpm installs beside the wired entry: the reference is withheld (`patched_ref_unattributable`). A root-relative `.socket/vendor/npm/…` path in a per-member or Rush lock is diagnosed (`patched_ref_invalid`), never a reference: pnpm resolves a lock's `file:` tarball against that lock's own directory, so it does not name the committed artifact, and no vendor backend writes one there. Run from a pnpm workspace member, the member's own `pnpm-lock.yaml` / `shrinkwrap.yaml` is read as absent while the workspace shares one lock (the nearest ancestor `pnpm-workspace.yaml` lists the directory and does not set `sharedWorkspaceLockfile: false`, or its root lock lists member importers; a workspace file above the repository root is never read and counts as listing it): pnpm installs the member from the root's lock, so the member lock's references are not attested and one `patched_ref_unattributable` names the workspace root to run from. pnpm 11+'s env lockfile document (config dependencies, the pinned package manager) is read as its own install tree: its references neither contest nor are contested by another lock, and its entries never contest the project document's references. One of its references is never attested while the project document of the same lock resolves the same `name@version` from elsewhere, because a VEX statement covers the release, not one install tree: a registry entry or a `file:` copy withholds it with `patched_ref_unattributable` (re-running `scan` rewires a registry entry), and a bundled copy omits it like any pnpm bundled copy (`vex_pnpm_bundled_copy`). | +| yarn | `yarn.lock` (classic and berry). Run from a yarn 1 workspace member (the nearest ancestor `package.json` with `workspaces` lists the directory), the member's own classic `yarn.lock` is read as absent: yarn 1 installs the member from the workspace root and never reads it, so its references are not attested and one `patched_ref_unattributable` names the root to run from (a berry lock in a member is a project of its own). A berry entry yarn writes under a YAML complex key (`? ""`, a lone `:`, then the body: yarn's spelling of a key longer than 1024 characters) is read as an entry like any other: the lock stays readable, and what the entry resolves counts as evidence | classic `resolved`; berry entry keyed and resolved `@` + root `package.json` `resolutions` `"@npm:": ""` (older releases: `resolution: …::__archiveUrl=`) | classic `resolved "file:./.socket/vendor/npm/…#"`; berry `file:` entry **plus** a root `package.json` `resolutions` mapping onto the same artifact (without it the entry is orphaned: diagnosed, no ref) | classic `integrity` / `#sha1`, berry `checksum`, required | | bun | `bun.lock`; `bun.lockb` only when there is no `bun.lock` (bun reads exactly one) | URL tuple / binary remote-tarball resolution; version from the URL leaf. A user URL or `file:` tarball tuple / record whose `-.tgz` leaf names the same `name@version` is installed from its own spec, so every ref for that `name@version` is dropped | `.socket/vendor/npm//-.tgz` tuple / local-tarball resolution | `sha512-…`, required. A 2-tuple that Bun < 1.3.10 re-saved without its digest is still a reference, but it attests only from an installed tree. | | vlt | `vlt-lock.json` (lockfileVersion absent, `0` or `1`; a BOM-prefixed, unparseable, non-object or other-version lock is not read: diagnosed, no ref). `vlt.json` (read only for its `modifiers`) and `node_modules/.vlt-lock.json` are never wiring. | a registry node (any segment) whose slot [3] is a `/patch/npm/…` URL on the patch host with the leaf `-.tgz` of its DepID's `name@version`, whose embedded `/` path (when present) is that `name@version`, and slot [1] == name; version from the DepID | a `file` node `.socket/vendor/npm//-/node_modules/` (or a user-installed `-.tgz`) with slot [1] == name; version from the path. A same-`name@version` registry node, or a diagnosed Socket-shaped one, beside it is diagnosed, no ref. | slot [2] `sha512-…`, required (a hosted node without one is no reference). A same-`name@version` node on another registry, or a diagnosed Socket-shaped one, keeps the reference but withholds the lockfile basis: only an installed tree whose every store copy verifies attests. So does a lock some vlt release discards, the conditions of `redirect_vlt_lockfile_version_missing`, `redirect_vlt_old_lockfile_ignored` and `redirect_vlt_scalar_registry_ignored` (which in-run `--vex` withholds too): every hosted reference in it keeps no pin, and one `patched_ref_unattributable` names them. | -| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config` (else `.cargo/config.toml`) | `Cargo.lock` `source = "sparse+…//index/"`, confirmed by `Cargo.toml`: a crate the root manifest declares must pin `registry = "socket-patch-"`. A reverted pin is diagnosed, no ref. Another `[[package]]` of the same `name@version` from a non-Socket source (crates.io, git), which cargo locks beside the pin for a dependent added later and builds unpatched (#679), withholds the ref (`patched_ref_unattributable`, never attested) while `rollback` / `remove` still unwind it. | `[patch.] = { path = ".socket/vendor/cargo//-" }` — primarily the root `Cargo.toml` (v5 `vendor`; key-agnostic: `` is `package` when renamed, else the key, so `-socket-` keys count), also the project config (pre-v5 wiring), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]`; a manifest entry cargo ignores — the project config redefines its key with another path, or a `[patch."https://github.com/rust-lang/crates.io-index"]` table replaces `[patch.crates-io]` — is diagnosed (`patched_ref_invalid`), no ref | `checksum` (v1: `[metadata]`), required | +| cargo | `Cargo.lock`, `Cargo.toml`, `.cargo/config` (else `.cargo/config.toml`) | `Cargo.lock` `source = "sparse+…//index/"`, confirmed by `Cargo.toml`: a declaration cargo honors must route that version to the patch, either a dependency in the root manifest or a workspace member's with `registry = "socket-patch-"` and a requirement that accepts the locked version, or a `[patch.]` entry carrying that registry (in the root manifest, or the project config). Cargo resolves a dependency from the source its declaration names, so without one cargo takes the crate from crates.io and rewrites the lock, or fails under `--locked` (#2045). A reverted pin is diagnosed (`patched_ref_invalid`), no ref. A crate only registry dependents declare, a registry named only on a `[patch]` entry cargo ignores or a `[workspace.dependencies]` entry nothing inherits, and a lock with no readable root `Cargo.toml` withhold the ref (`patched_ref_unattributable`, never attested) while `rollback` / `remove` still unwind the lock entry. Run from a workspace member (an ancestor `[workspace]` that does not `exclude` the directory, or `package.workspace`; the check hosted scan and `vendor` refuse a project by), nothing there attests: cargo ignores a member's `[patch]` and reads `Cargo.lock` only at the workspace root, so each member file naming a Socket patch is diagnosed (`patched_ref_unattributable`, naming the workspace root to run from), a vendored `[patch]` copy is no reference, and a hosted pin is only one `rollback` / `remove` unwind. Another `[[package]]` of the same `name@version` from a non-Socket source (crates.io, git), which cargo locks beside the pin for a dependent added later and builds unpatched (#679), withholds the ref (`patched_ref_unattributable`, never attested) while `rollback` / `remove` still unwind it. | `[patch.] = { path = ".socket/vendor/cargo//-" }` — primarily the root `Cargo.toml` (v5 `vendor`; key-agnostic: `` is `package` when renamed, else the key, so `-socket-` keys count), also the project config (pre-v5 wiring), live only while the lock holds a sourceless entry for it that is not in `[[patch.unused]]`; a manifest entry cargo ignores — the project config redefines its key with another path, or a `[patch."https://github.com/rust-lang/crates.io-index"]` table replaces `[patch.crates-io]` — is diagnosed (`patched_ref_invalid`), no ref | `checksum` (v1: `[metadata]`), required | | golang | `go.mod`, `go.work`, `go.sum`, `go.work.sum` | `replace M v => patch.socket.dev/gopatch/ ` | `replace M v => ./.socket/vendor/golang//M@v` | the socket module's `/go.mod` go.sum line, required. The zip line (`h1:` of the module tree) is the locked integrity when present; `go mod tidy` prunes it when no package of the module is loaded and the build stays green, so the `/go.mod` line alone still attests from the lockfile, unless the main modules' `require`s or go.sum show the build selects another version (MVS keeps the `/go.mod` line of versions it only reads): then the replace is inert. With a `go.work` the main modules are its `use` members and the build selects by MVS over their module graph: each member's `require`s and the `go.mod` of every dependency go loads (read from its replacement directory or the module cache when the root is on disk). go keeps no member's `go.mod` tidy in workspace mode, so a member's dependency can raise the module past every `require`. A pin, zip line or not, whose replaced version a member's or a dependency's `require` raises is inert, as is one of a module that is itself a `use` member (go builds it from that directory). Sum-file zip lines are no evidence of the selection (go only appends to `go.work.sum`), and a `/go.mod`-only pin is never the lockfile basis in a workspace (`integrity_required = true`). Any mode writes the replace of a module a workspace shares into the root `go.work` (source `go.work`); a `go.work` root without a `go.mod` also reads each in-root member's `go.mod` replaces (source `/go.mod`). On disk the workspace is the one go resolves, read as hosted and vendored modes read it: `GOWORK`, else the nearest `go.work` at or above the project, with `use` members anywhere (in memory: the root `go.work` and its in-root members). The sum files are the ones go reads: the root `go.sum`, or with a `go.work`, its `go.work.sum` and each `use` member's `go.sum` (the root's only when `.` is a member), those outside the project only when inside the repository; under `GOWORK=off`, `go.work` and its replaces are ignored. A Socket replace that workspace keeps from the build, exactly where hosted and vendored modes refuse to write one (its `go.work` is an ancestor's or another file `GOWORK` names, so the root `go.work` is never read; it overrides the replace; it doesn't `use` the module; another member replaces the same `module@version` elsewhere), stays a reference but is unattested (`go_workspace_override`) for that file only: the root `go.mod`'s mirror of a `go.work` replace go never reads still attests. A replace of a module that is itself a `use` member is inert, diagnosed, never a reference. A `GOWORK` go rejects (a relative path, a file it cannot read or parse) reports each Go ref as `patched_ref_invalid` instead, as does a `GOFLAGS` `-modfile` other than the root `go.mod`. A conflicting hash or a malformed `h1:` for the socket module+version, or a line of other than three tokens anywhere in a sum file, is no pin. A replace that `require` no longer selects (`require M v'`) is inert: diagnosed, no ref. | | pypi | `uv.lock` (confirmed by `pyproject.toml` `[tool.uv.sources]` when present), PEP 723 `