Skip to content

Fix the defects found by the 5.1.0 full review: do no harm, Bundler hosted mode, VEX, monorepo scans - #2052

Merged
Mikola Lysenko (mikolalysenko) merged 140 commits into
mainfrom
fix/full-review-5.1.0
Nov 11, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 140 commits into
mainfrom
fix/full-review-5.1.0

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

What this is

One pull request for the fixes that came out of a full review of socket-patch 5.1.0 (every production file read by 20 independent auditors, then a hands-on smoke test with real package managers against the public patch catalog). The review was judged against one rule: patching must never break the user's code, and a failed or skipped patch must be reported, never hidden.

It merges 24 fix branches, each started from a test that failed on main, plus the commits that reconcile them with each other and fix what the pre-PR code review found.

Changelog: the 5.1.0 notes for these changes (82 entries, each stating any exit code, vex statement, file write or refusal it changes) are in #2053, on a release/v* branch, because AGENTS.md lets only a release branch edit CHANGELOG.md. They were first pushed here and a repository routine restored the file. Merge #2053 after this PR.

The 631 audit findings and the smoke-test results are in the owner's audit report.

What changes for users

Do no harm (agent, hosted and vendored modes)

  • A global scan --prune / --sync run from a project no longer empties that project's manifest and deletes its blobs; nothing is swept after a failed manifest write.
  • A scan that reached something other than the patch API (a captive portal, a wrong --proxy-url, a bare 404) fails instead of printing "no patches".
  • Applying or rolling back a package is all-or-nothing and keeps file modes even when interrupted. A checksum sidecar that cannot follow the patched files now fails the package and puts its files back.
  • Hosted multi-file writes and restores are checked up front and undone on failure. Patch grants are validated field by field before anything reaches a lockfile.
  • The commit journal can no longer be replayed into .git; a vendor ledger can only undo socket-patch's own edits; spawned tools do not inherit Socket credentials.
  • Crafted project files (lockfiles, POMs, bun.lockb, Gradle builds) no longer panic, hang or exhaust memory.
  • Telemetry never delays output, makes no call from local-only commands, honors DO_NOT_TRACK, and is documented.

RubyGems / Bundler (hosted mode)

  • The Gemfile edit appends source: "<url>" to the user's own gem line instead of replacing it with a source … do block: version ranges, comments, quoting, gem(...) and multi-line declarations are kept, and rollback restores the file byte for byte.
  • Locks written by Bundler older than 2.6 (no CHECKSUMS) are converged to exactly what that Bundler's bundle lock writes, so frozen and deployment installs pass (they failed with exit 16).
  • After a scan, gems already installed inside the project (and vendor/cache archives) are replaced with the verified patched gem, so the patched code runs with no further command; rollback puts the upstream gem back. A shared gem home is left untouched with one short instruction (--gem-refresh-shared opts in).

VEX

  • vex withholds a statement whenever the lockfile evidence does not back it: vendor ledger entries no lockfile wires, hosted URLs clients read differently, locks the package manager is told not to read, second copies of a release, PEP 723 script locks, workspace members' stray locks (JS, Python, pnpm, yarn, Gradle); a Cargo.lock entry no declaration routes, a gem whose Gemfile does not name the source its lock does, a Maven <dependencyManagement> pin a parent pom overrides or that sits under an unread parent, and a Composer install the crawler cannot read. --no-verify says so when nothing was verified.

Scans in a monorepo, and output

  • A bare scan inside a git checkout finds every project below it by lockfile; list and rollback at the same directory cover the same projects; the other commands name the projects to run in.
  • Scan output names the advisory each applied patch fixes and the ones left unpatched (one patch is applied per package version), agent mode ends with its two next steps, and "Manifest not found" in a project with no patches now says so.

Vendored mode

  • pnpm and yarn berry edit package.json by byte span instead of re-serializing it; reverts never delete an artifact a lockfile still references. uv rollback works with one dependency and vendored uv keeps the locked version. Vendored Maven POMs resolve what their upstream POMs do. bun.lockb and vlt reverts are exact and bounded.

How it was verified

  • Full test run on the merged branch (cargo test -p socket-patch-core, -p socket-patch-cli, --no-fail-fast): 8,377 core and 4,873 CLI tests passed. The 5 failures were 2 source-reading guards and 1 real defect, all fixed in the two commits that follow (and re-run), and 2 e2e_vendor_cargo_build old-toolchain tests that fail on this machine only (the pinned rustup 1.41.1 toolchain is x86_64 and the host has no Rosetta).
  • After every later merge and fix: the affected core and CLI suites, cargo fmt --all -- --check and cargo clippy --locked -p socket-patch-core -p socket-patch-cli --all-features -- -D warnings, all clean.
  • Real-toolchain suites that normally run only in CI (--ignored), 30 binaries on macOS arm64: npm, pnpm (7, 8, 10, 11 via corepack), yarn, uv, Poetry, Bundler 4.0.15, Go, Cargo, Maven, .NET and the live-API suites. Nothing fails on this branch that passes on main. Four tests fail on both: e2e_scan::test_scan_json_read_only_emits_updates_array (stale since Decide: make the command model read-only scan plus fix/undo/sync, with mode taken from project state, and make "nothing to undo" exit 0 #1088: a bare scan keeps an agent project's mode), e2e_pypi (no pip on this machine), the yarn-berry install proof (local yarn is 1.22) and the gem monorepo crawl gap.
  • This PR's CI (Linux): all checks passed on 87de43a3d and again on aaeeaefff, including the whole workspace under coverage and the real-toolchain suites this machine has no tools for (bun, vlt, Composer, Gradle, PDM, Hatch, Pipenv). Skipped for pull requests, so not run anywhere yet: the macOS and Windows end-to-end jobs, the Windows compile check, the Docker / extended / full end-to-end jobs (sbt among them) and the old Cargo toolchain matrix. The Windows paths of the new write engine, the gem refresh and the closed-pipe handling were reviewed, not run.
  • Smoke test with the built binary against the public patch catalog, real package managers, throwaway projects: npm, pnpm 11, yarn 1.22, pip, uv, Poetry, Go, a three-project monorepo, agent mode with a native-extension gem, and Bundler 4.0.15 and 2.5.23 in hosted mode (project-local and shared gem homes, path vendor/bundle, transitive gems, unusual declaration forms, frozen and deployment installs, scan → rollback byte-exactness).

Behaviour changes to confirm in review

These are deliberate, and each is a place where a working setup can notice a difference. The changelog entries state every exit-code change, withheld vex statement, new file write and new refusal in full.

  1. Checksum-sidecar failure (agent mode) now undoes the package and exits 1 (was: files left patched, exit 0, and cargo then refused to build).
  2. repair --offline exits 1 when blobs are missing or corrupt (was: a note and exit 0).
  3. vex is stricter. In particular:
    • a vendored package whose lockfile was reverted while the manifest still points at the vendored copy (a pnpm override, a Gemfile path:) is no longer attested until one plain install puts the lock back. 5.1.0 attested it on purpose; the two real-tool suites that pinned that are updated;
    • Maven: a <dependencyManagement> pin for a package the root pom does not declare is not attested from the pom alone when the parent pom cannot be read (<relativePath/>, which Spring Initializr writes). Standalone vex reads package_not_found there until a build has resolved the patched version. A remote parent that declares the package with a literal version makes Maven build the upstream jar (checked with Maven 3.9.16), which is why. Relaxing it is a one-line change if the owner prefers the statement;
    • PEP 723 script locks now count, and a gem or crate is attested only when its manifest routes it to the source its lock names.
  4. Hosted gem edits use a new shape, refuse a gem declared inside the user's own source … do block or with its own source:/git:/path:, and refuse a lock with neither BUNDLED WITH nor CHECKSUMS. The rewriter's edit kind redirect_gemfile_source_block is replaced by redirect_gemfile_source_option / redirect_gemfile_dependency_line. Projects pinned with the old block form keep working and are not migrated.
  5. A hosted scan now writes into the project's gem home (vendor/bundle, a project-local GEM_HOME, vendor/cache) and can compile a native-extension gem while doing so. --no-gem-install-refresh turns it off.
  6. Plain-http patch grants are refused unless that origin was configured with --patch-server-url; a configured origin is compared on scheme as well as host.
  7. A bare scan in a git checkout touches every project below --cwd, not only --cwd (approved by the owner on 2026-11-10); a bare rollback at that directory undoes the same set after one confirmation. Submodules and nested clones are never entered.
  8. Hosted scan / get from a pnpm or yarn 1 workspace member that has its own lock now exit 1 (redirect_workspace_lockfile_elsewhere) instead of pinning a lock the package manager never reads. Workspaces with sharedWorkspaceLockfile: false are unaffected.
  9. uv vendored mode keeps the locked version and adds two refusals for lock shapes it cannot edit safely.

Not in this PR

  • depscan's TypeScript gem rewriter shares tests/fixtures/redirect/gem/bundler/basic/expected/{Gemfile,expected-edits.json}, which change here; it needs the matching change. depscan's suffixMavenPom has the vendored-POM defect fixed here on the CLI side.
  • Left for the owner to decide: the empty .socket/manifest.json after a full rollback, exit codes when there is nothing to do, apply --check at a monorepo root, binding a hosted lock pin to the patched artifact's digest for vex, machine-wide Python fallback in hosted mode, the telemetry default, and release-pipeline approvals.
  • Not started: performance and simplification findings, and the Bundler follow-ups (bundle outdated no longer listing a hosted-patched gem; a false vendored-mode vex warning).

Code review

/code-review high ran over the whole diff before this PR was opened (291 files, about 53k added lines), in two passes because of its size. The first read the CLI command layer, project discovery, the API client, the gem refresh, the apply/rollback write path, the lock and the stdio/process/fs utilities. The second read the group commit, the hosted rewriters and their restore, the gem and yarn/pnpm format readers, the Gradle code, VEX discovery for npm, PyPI and gem, and most of the vendor backends. Neither read every file: the second half of vendor/jvm/maven_reactor.rs, the vlt, uv, yarn and legacy-pnpm vendor backends, formats/maven, and the uv/cargo/nuget restores were only searched for panic-prone patterns.

First pass: ten candidates. Seven are fixed in 9bad0dbdd (plus 87de43a3d, an import-layering slip in that fix which CI caught):

  • project discovery entered git submodules and nested clones, and honoured .ignore files git does not;
  • a bare rollback reached further than a bare scan from an agent-mode project, and rollback --json could roll back a subdirectory;
  • a targeted rollback walked the whole checkout before running;
  • an original a failed undo kept could be swept ten minutes later;
  • the gem refresh staged through a .socket-patch-refresh symlink;
  • scan and list/rollback printed different == <dir> == headings on Windows.

Three are not fixed:

  • The API client treats a non-service error body as "not the patch API" (candidate: a proxy that refuses a purl with another body shape now fails the scan). Checked against patches-api.socket.dev: an unknown or malformed purl in POST /patch/batch is answered 200 with a skipped[] entry, and both 400 shapes the per-package route sends are ones the client accepts. Failing on any other shape is the point of the fix.
  • list and rollback take the socket.yml bypass only from SOCKET_NO_SOCKET_YML, while scan also has --no-socket-yml. A project scanned with that flag is not listed or rolled back from the root unless the variable is set. Left as documented behaviour: adding the flag to two more commands is an interface decision.
  • list reads each project's stores twice, and a non-silent apply with no manifest searches the directories below. Measured at about 0.15 s on this repository with the debug build; apply --silent does not search. Left as a performance follow-up.

Second pass: four candidates. Three are fixed in aaeeaefff:

  • the hosted gem rewriter could append source: after an options hash when another argument was an array literal, or when a gemspec directive supplied the lock's requirement, leaving a Gemfile Bundler cannot load;
  • a vendored revert decided what it may delete by searching warning text that can quote ledger content;
  • the hosted takeover read a finished "dependency moved on" revert as one that kept its artifact.

One is not fixed: each bun.lockb ledger record carries the pre-vendor lock when the lock needed normalizing, so the ledger grows with the number of vendored packages. Storing it once needs a ledger-level field older CLIs would not read; it is on the owner's decision list.


Generated by Claude Code

…king

`rollback` and `remove` restore a hosted cargo pin by looking its crate
up in the crates.io sparse index. The index path was cut from the lock's
`name` at byte offsets 1, 2 and 4, so a `[[package]]` on a hosted source
named `""` or `"éa"` panicked (exit 101, "byte index 1 is not a char
boundary") and no restore could run on that checkout.

crates_index_path now returns None unless the name is non-empty ASCII
letters, digits, `-` and `_` (crates.io's alphabet), and cargo_cksum
refuses the pin with `"éa" is not a crates.io crate name` before any
request, offline or not. The slices are then only taken from ASCII.

Checking the rest of the cargo restore for the same input class found a
second panic: a v1 lock that holds the same hosted block twice beside a
crates.io twin (cargo refuses such a lock, TOML parses it) queued the
one `[metadata]` checksum line for removal twice, and the second
`replace_range` ran past the end of the text. Equal splice ranges are
now applied once.

Tests (each fails on the old code with the panic):
cargo_cksum_refuses_a_name_crates_io_cannot_hold,
restore_refuses_a_hosted_block_named_like_no_crate,
restore_merge_drops_a_shared_metadata_line_once.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The NuGet restore decides which source a restored id resolves from by
scoring each `<package pattern="…*"/>` of the config. pattern_score cut
the id at the prefix's byte length, guarded only by the id being at
least that long. NuGet ids admit Unicode letters, so an id such as
`Ünicode.Pkg` beside a mapping `A*` panicked `rollback` ("byte index 1
is not a char boundary").

The prefix is now taken with `str::get`, so a length that falls inside a
character is a pattern that does not match. A prefix cannot equal the
id's bytes up to the middle of a character, so no match is lost.

Test: pattern_scores_never_split_a_character_of_the_id (panics on the
old code).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`scan --prune`/`--sync` under `--global` or `--global-prefix` judged the
manifest against the global crawl alone. The manifest is still
`<cwd>/.socket/manifest.json`, so every record of the project read as
uninstalled: the run emptied the manifest and deleted `.socket/blobs/`,
exit 0. The project's `apply` then had nothing to re-apply and `rollback`
nothing to restore from. A global prune now also keeps every entry the
`--cwd` project accounts for, its installed packages and the ones only
its lockfiles list, which is what the project's own prune keeps. It
removes only entries neither scope has.

Two more ways the same GC destroyed data it should have kept:

* When the pruned manifest could not be written, the sweep still ran
  against the pruned in-memory copy and deleted the blobs the manifest on
  disk still names. A failed write now prunes and sweeps nothing, and
  `manifest_write_failed` says so.
* The blob, diff and package sweeps followed a symlinked `.socket/`
  level. A checkout can commit `.socket/packages -> ../../elsewhere`, and
  `repair`, `remove`, `rollback` and `scan --prune` then deleted every
  regular file there as an orphan; the dry run listed them too. The sweep
  refuses a linked level with the containment refusal every other writer
  and deleter of `.socket/` uses, reported as `cleanup_failed`.

Found by the 2026-11-10 full review. The first was reproduced with the
5.1.0 binary: a project with one installed, patched package, scanned
with `--global-prefix <dir> --sync`, was left with `{"patches": {}}` and
no blobs. Each case has a test that fails without the change:
scan_global_prune_keeps_the_projects_entries,
scan_prune_keeps_blobs_when_the_manifest_cannot_be_rewritten and
sweep_refuses_a_linked_artifact_dir_and_spares_its_target.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…m names

First-contact fixes from the 2026-11-10 usability review. None changes
what a command does.

* Help wraps to the terminal, capped at 100 columns. Lines ran to 564
  characters in `--help` and 501 in the short `-h` summaries, which broke
  the two-column layout in an 80-column terminal. A new test bounds every
  help page's line width.
* Root help defines the three patch modes. It used "hosted", "vendored"
  and "agent-mode" without saying what they are; the only definitions
  were under `--mode` in `scan --help`.
* `--ecosystems` and socket.yml `patches.ecosystems` accept the names
  users reach for. The CLI prints "Found 5 packages (5 go)" and "(61
  python)" but rejected `-e go` and `-e python`, and socket.yml answered
  `go` with "did you mean `gem`?". Both now resolve through one
  `Ecosystem::from_name`: the slugs in any case, the display names, and
  common registry, language and package-manager names. The value is
  normalized to the slug at parse time, so later comparisons are
  unchanged. `--ecosystems` help lists the slugs.
* The npm launcher explains a missing platform package. With
  `npm install --omit=optional` it printed `spawnSync
  .../socket-patch-darwin-arm64 ENOENT`; it now names the package, why
  npm skipped it and how to get the binary. An unsupported platform lists
  the supported ones.
* README no longer opens by saying it documents a prerelease that may
  differ from what the install commands fetch. 5.0.0 shipped on
  2026-10-12, and the note is copied into the npm and crates.io pages.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`repair` only checked that each referenced blob existed. A blob whose
bytes changed on disk (a `core.autocrlf` checkout of a committed
`.socket/blobs/`, a truncated file) read as "present": repair said "All
blobs are present locally" and every `apply` failed on it with
"Hash verification failed before patch. Expected …, Got …", which names
neither the file nor a remedy (NT-D4, PE-18). And `repair --offline`
with missing blobs printed a warning, said "Repair complete." and exited
0 (`--json`: `success`, no event), while the next `apply --offline`
failed and told the user to run `repair` (CA-D05).

repair now re-hashes every referenced blob it finds (afterHash and
beforeHash) against its git-sha256 name and treats a mismatch like a
missing blob: downloaded again and renamed over the bad entry, which a
failed download leaves as it was. Offline nothing can be fetched, so
each missing or corrupt blob is a `failed` event
(`offline_missing_sources` / `blob_corrupt`, `details.hash`), an
`Error:` list on stderr that `--silent` does not mute, a closing
"Repair finished with errors" line and exit 1, as the contract's
`--offline` rule ("fail loudly when a required local source is
missing") already said.

apply and rollback keep hashing a blob only when they are about to
write its bytes. The refusal is now typed, so the caller that fed the
writer a cached blob reports "blob .socket/blobs/<hash> is corrupt …
Run `socket-patch repair`" without hashing anything again.

The repair suites' fixtures wrote placeholder bytes under a made-up
hash; they now use the real hash of their content. Verified with the
core unit tests (corrupt-blob detection, the apply and rollback
messages), repair's unit tests and the `repair`, `e2e_safety_cow` and
`in_process_remove_repair_lifecycle` binaries.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… one

Hosted rollback rebuilds a uv.lock entry from PyPI in the shape another
registry package of the lock shows: which registry it names, and which
artifact fields this uv records. A project with one dependency has no
such package, so `socket-patch rollback` failed with "no sibling registry
package shows the registry and artifact fields the lock records" and left
pyproject.toml and uv.lock hosted. The hosted scan wires such a project
without complaint, so the only way back was `git checkout`.

A `[[package]]` lock (uv 0.2.35 and later, script locks included) says
what a sibling would in its header. With no sibling, the entry is now
rebuilt on PyPI's default index with the fields uv writes at the lock's
`revision`: `url`, `hash` and `size` with no revision and at revision 1,
those plus `upload-time` at revisions 2 and 3. The run reports it with an
`upstream_uv_entry_rebuilt` warning naming the entry, the fields and the
`uv lock --check` to run. It still refuses, with nothing written, when
the revision is newer than 3 (its fields are unknown here) and when a uv
setting or environment variable names a package index, since the entry's
registry is then not known to be PyPI. A pylock or a uv 0.2
`[[distribution]]` lock with no sibling refuses as before: nothing in
them tells their writers' shapes apart.

A refused uv pin's remedy now names every file the hosted rewrite edited.
Discovery lists only the lock, so the advice was `git checkout -- uv.lock`,
which leaves the `[tool.uv.sources]` URL in pyproject.toml (or the script)
for the next `uv lock` to wire back in. The paired file is named when it
carries the pin's patch.

Verified with real uv. A `uv init` + `uv add "urllib3==1.26.18"` project
taken through the real hosted rewriter: the 5.1.0 binary refuses it as
reported; with this change rollback exits 0, both files are byte-identical
to what uv wrote, `uv lock --check` passes and `uv sync --frozen` installs
urllib3 1.26.18. The same cycle is byte-identical on uv 0.2.37, 0.3.5,
0.4.30, 0.5.31, 0.6.14, 0.7.0, 0.8.0, 0.8.4, 0.9.30, 0.10.0, 0.11.19 and
0.12.17. uv 0.6.15-0.6.17 spell the revision 2 timestamp `upload_time`;
their entry comes back as `upload-time`, which uv 0.6.17 accepts
(`lock --check` and `sync --frozen`), as 0.7.0 and later accept
`upload_time`. A script lock with one dependency round-trips too
(`uv lock --check --script`).

The real-uv e2e driver accepted either a restore or a refusal for its
one-dependency lanes. It now requires the project, constraints and script
lanes to restore byte for byte on every `[[package]]` lock (not
byte-exact on 0.6.15-0.6.17). `e2e_redirect_uv_build` passes those lanes
on the 16 uv releases of the CI matrix, 0.1.45 through 0.12.17.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
socket-patch spawns package managers and their version-manager shims,
interpreters, git, and for a gem's native rebuild the gem's own
extconf.rb and the compiler. CI usually exports SOCKET_API_TOKEN for the
run, often on a repository the CLI does not control, and every one of
those children inherited it: the spawn seam in utils::process removed
nothing, and the gem build dropped only RUBYOPT, RUBYLIB and BUNDLE*.

utils::process now owns one list, CHILD_ENV_SCRUB: the Socket token under
each name a Socket tool reads it from, socket-cli's inline config and
forward proxy, the accepted peer aliases, every flag-bound variable of
the CLI and the Socket-specific registry override URLs (a URL can carry
credentials in its userinfo). command_for removes them when a command is
made, and output_within / build_output_within remove them again at the
spawn, because a caller may rebuild the environment from the ambient one
afterwards (the PDM site probe does env_clear().envs(..)). Names are
exact, so HTTP_PROXY, HTTPS_PROXY, NO_PROXY, NPM_TOKEN and any other
variable a package manager or an .npmrc expansion needs still reach the
child.

The two production commands made outside command_for (the staged
binary's --version self-check and the git check-ignore pipe) now go
through it. Nested apply from get/scan runs in-process, so no socket-patch
child depends on these variables; the self-check only parses --version.

Tests, each failing before the change:
- utils::process: a child run by output_within and by build_output_within
  with the token set on its command does not see it and keeps NPM_TOKEN /
  HTTPS_PROXY / NO_PROXY; command_for removes the list from the inherited
  environment; the list covers both columns of PEER_ENV_ALIASES and holds
  only SOCKET_* names.
- child_deadline_guard: production code makes a Command only in
  utils::process (found update/download.rs and vendor/npm_dir.rs).
- args: every env binding clap declares on any subcommand, and both
  ARG_ENV_VARS lists, are on the scrub list, so a new token-bearing
  binding cannot be forgotten.
- e2e_gem_native_ext_build: with SOCKET_API_TOKEN, SOCKET_CLI_API_TOKEN
  and SOCKET_SECURITY_API_TOKEN in the CLI's environment, the Ruby that
  rebuilds a gem's extension sees none of them (a stand-in ruby on PATH
  records its environment), and still sees NPM_TOKEN.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`partition_purls` silently dropped every manifest key without a known
ecosystem: a purl type a newer socket-patch recorded (`pkg:swift/…`), a
known type in another case (`pkg:NPM/…`), a key that is not a purl. So
`apply` printed "No patches to apply." and exited 0, `apply --check`
passed with the records unverified, `rollback` counted them in its
prompt and then did nothing, and `remove` dropped their records without
restoring anything, while `list` showed them all (CA-D03).

One classification (`unsupported_keys`) now feeds every command that
partitions the manifest:

- An unsupported ecosystem is a `skipped` / `unsupported_ecosystem`
  event per record, one `warnings[]` entry and one stderr warning naming
  them (with the lowercase spelling for a mis-cased type). It never
  changes the exit: a newer CLI's ecosystem in a committed manifest must
  not fail an older CLI's CI run, the same reason the scan prune exempts
  such records. `apply --check` no longer says "in sync" over them; its
  line counts them as not checked. `rollback` keeps them out of the
  prompt and keeps their records.
- A key that is not a purl at all can never be matched by any version:
  `apply` fails it by name (the other patches still apply),
  `apply --check` reports drift (`invalid_purl`) and `rollback` a failed
  entry.
- `remove` refuses either kind before any mutation, since it cannot
  restore what the record patched; `--skip-rollback` still forgets it.

Nothing is reported under `--ecosystems`, which scopes a run to
ecosystems this CLI knows.

Each new test failed before the change: four in the `apply` binary, one
each in `rollback` and `remove`. Those three binaries and the unit
tests for apply, rollback, remove and ecosystem_dispatch pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
scan and get could report a clean result for a question nobody answered.
On the token-less path a rejected `POST /patch/batch` (400/404/405)
degraded to one GET per package, and every per-package error that was
not a throttle was dropped as "no patches": a mistyped SOCKET_PROXY_URL,
a captive portal, a forward proxy given as --proxy-url or a service that
500s every lookup all ended in "No patches available for installed
packages.", exit 0, `status: success`. Separately, every 404 on a JSON
route read as "not found", so a mistyped --org (the API's 404
"Organization not found") or a host that 404s everything made `get`
print "No patches found" and exit 0.

The client now tells the service's answer from another host's by its
shape, not its status. The service writes errors as
`{"error":{"message":…}}` (API) or `{"error":…,"message":…}` (proxy):

- A 404 is "not found" only with the service's error body, and not when
  that body says the organization or route is missing. A 404 page from
  another host, a 4xx the service did not write and a 200 that is not
  the expected JSON are errors naming the host that answered and the
  setting to check (--proxy-url / SOCKET_PROXY_URL, or --api-url and the
  org slug). A 404 with no body fails a search unless the service has
  already answered this client's batch query (scan's detail queries); a
  patch view keeps reading it as "no such patch".
- The service refusing a request's content (its own 400) is the new
  `ApiError::InvalidRequest`. It is the one per-package outcome that
  still counts as "nothing for this purl".
- The batch POST degrades on a 400 only in the service's shape. A 400
  another host wrote fails at once instead of fanning out one GET per
  package to the same wrong host.
- On the per-package path a lookup that got no answer is returned in
  `BatchSearchResponse::unanswered`; when no lookup was answered the
  call fails with the first error. This covers a throttle that outlasted
  its retries (it used to fail the whole batch), the proxy's "Patch API
  is not configured" 503 (it used to read as no patches) and a lookup
  task that died.

scan uses the answered packages and reports the rest: a
`patch_lookup_failed` warning naming them, a `failed` event each,
`partialFailure`, exit 1 in every mode. A scan that found no patch but
could not check every package no longer prints "No patches available".
The in-memory hosted engine counts unanswered purls as failed purls.

The documented legacy fallback (the proxy's 400 "Unsupported endpoint"
followed by healthy per-package answers) and its honest empty answers
are unchanged. CLI_CONTRACT.md states the new reporting.

The new wiremock tests in core tests/proxy_batch_e2e.rs and CLI
tests/scan/scan_proxy_fallback_e2e.rs fail on the previous code (the
client returned an empty Ok; scan exited 0 with `status: success`). The
5.1.0 binary run against a stub answering the API's "Organization not
found" printed "No patches found for CVE" / "No patch found with UUID"
and exited 0; this build exits 1 naming the slug. The existing API
suites and the scan/get suites that mock the service pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… --global

`gem env gemdir` / `gem env gempath` and the Python probes
(`python3 --version`, then the `site` query) ran in whatever directory
the CLI was started from. A version manager's shim (rbenv, pyenv, asdf,
mise) picks the interpreter from that directory's `.ruby-version` /
`.python-version` / `.tool-versions`, so `socket-patch scan --cwd proj`
run from elsewhere crawled another Ruby's gem homes or another Python's
site-packages, and under `--global` the project the shell happened to be
in steered a global scan. The npm family and Composer already ran their
global probes from the neutral directory.

SystemCommandRunner, which inherited the process cwd, is replaced by
ProbeRunner, which always names the directory: ProbeRunner::project(cwd)
runs from the project and ProbeRunner::global() from neutral_probe_dir
(what GlobalProbeRunner did; it now delegates). The Ruby crawler asks
`gem env` through project(--cwd) for the Bundler fallback homes, the
stale-install homes and the unused system homes, and through global()
for --global. The Python crawler asks through project(--cwd) for a
project that installs into the system Python, and through global() for
--global. The two memos are keyed on the directory the probe runs in
instead of the process cwd (EnvKeyedMemo::get_or_run takes it), so an
answer for one project is not reused for another.

Tests: global_probe_spawn_e2e gains four cases with a fake `gem` /
`python3` that logs the directory it runs from: under --global with the
process inside a project every probe runs from the home directory, and
for a project with the process started elsewhere every probe runs from
--cwd. All four failed before the change (the probes ran from the
process cwd). utils::process covers ProbeRunner's directories and the
memo asking again from another directory.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… patch

`remove` and `rollback` drop the manifest record of a patch whose
package the crawl did not find (the documented "long-uninstalled"
contract) and keep its beforeHash blobs out of that run's sweep. remove
then said the "revert data … was kept" and, under `--json`, that "a
later rollback/repair can still restore". Neither is true: the file to
hash map lived in the dropped record, so `rollback <pkg>` answers "No
patch found", `repair` restores no installed file, and the next sweep
collects the blobs. If the miss was a copy the crawl did not reach
(another --cwd, a layout it does not know), that copy stays patched and
tracked by nothing, and the message sent the user the wrong way (CA-D06).

The record removal and the blob retention are both spelled out in the
contract and pinned by tests, so they stay. What changes is what the
commands say:

- remove's warning and its `rollback_not_installed` event now state
  that nothing was restored and the record is gone, name the remedy
  that works (reinstall the package), and describe the kept blobs as
  what they are: usable only by a rollback after the patch is recorded
  again, and only until the next blob cleanup.
- rollback's not-installed warning adds one line for the entries whose
  record it drops, and its `package_not_installed` reason says nothing
  was restored.

`remove_not_installed_keeps_before_blob_and_warns` now also asserts the
reason and that a follow-up `rollback <purl>` finds no patch; it failed
on the old text. The remove, rollback and cli_remove_silent binaries
and the CLI unit tests pass.

Keeping the record instead (so a later rollback from the right scope
can still restore) would change documented behavior and is left for the
owner to decide.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A `workspaces` / `packages:` / cargo `members` glob is a project file,
and every matcher that reads one could be made to hang or crash by it:

- segment_glob_matches tried every way each `*` can end, so
  `build/*a*a*a*a*a*a*a*a*a*a*b` plus one 60-character directory name
  hung `scan` (about 10^11 steps). It is now the two-pointer wildcard
  match with one backtrack point, pattern length x name length at most,
  with no call per character and no allocation per step.
- path_glob_matches did the same per `**` segment: twenty `**` against a
  forty-deep path took about 10^15 steps. It now fills a table from the
  last segment back, one component match per (segment, component).
- The cargo / pnpm member walk (expand_glob, and the ProjectView one)
  reached one directory along every way to split the levels between two
  `**`: six `**` over a sixteen-deep chain listed directories 100,946
  times, and a truncated answer came back for `**/**/**` over forty
  levels. A walk with two or more `**` now remembers the (directory,
  segments left) pairs it has been through; consecutive `**` read as one.
- Brace expansion recursed per group and copied the pattern per level:
  `{a,b}` 4,000 times over overflowed the stack, and 100,000 times would
  build gigabytes first. It now keeps its own stack, pairs braces in one
  pass (a scan per `{` was quadratic), and stops at a megabyte of text
  as it already did at 4,096 alternatives. A zero-padded sequence wider
  than 65,535 (`{0...01..2}`) panicked in `format!`; it is padded by hand.
- The extglob matcher is memoized but still cubic in the name's length
  and recursive per pattern character: sixty `*(a|aa)` against a
  255-character name take seconds per brace alternative (hours behind
  twelve brace sets), and 2,000 `*` after a group overflowed the stack.
  One pattern now gets about a million matcher steps against one path
  and 512 levels of depth; a brace alternative with groups is read only
  up to 4,096 characters and 64 groups. Past any of these the pattern is
  unreadable, which fails closed the way a picomatch shape this matcher
  cannot read already did: it lists every directory and, as a `!`
  pattern, excludes none. A segment without a `(` goes to the linear
  matcher in every dialect, so it is read whatever its length.
- cargo's is_member_pattern looked for a `]` after every `[`: 40,000
  unclosed `[` took 11.6 s. The first `[` decides.

Semantics are unchanged for every pattern inside the bounds. The old
backtracking component matcher, path matcher and recursive brace
expansion stay in the tests as oracles: 40,000 seeded pattern/name
pairs, 40,000 pattern/path pairs over every dot / extglob / POSIX
reading and 20,000 brace patterns agree, as do 20,000 segments for the
member-pattern check. The step bound is twenty times what the costliest
ordinary group pattern in the tests takes against a 240-character name.

Each new test failed on the old code: the two timing tests and the view
walk timed out at 60 s, the listing count was 100,946 against a bound of
119, and the padded sequence panicked "Formatting argument out of
range". The stack overflows were confirmed by running the old code on
the same patterns; they abort the process, so they were not left in as
failing tests.

CLI_CONTRACT.md says which group patterns are not worked out.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A Hatch `matrix` is a product of its variables' values, and its names
were generated in full for any `pyproject.toml` or `hatch.toml` the
scanner met, before any environment directory was looked at: five
variables of sixteen values held a million names (408 MB), and a few
more variables are an out-of-memory kill.

The product is now walked one combination at a time, in Hatch's order,
and stops after 1,024 for the whole project file (every env and matrix
table counted together). Each combination is a virtualenv Hatch has to
build on disk, and real matrices are a few dozen, so the cap is far
above any project. A name longer than 255 bytes names no directory and
is not built, which also keeps a `matrix-name-format` that repeats its
placeholders from multiplying a long value into gigabytes; such a
combination still counts, so the walk always ends.

The names are only used for the shared `~/.virtualenvs` layout (the
other layouts list the project's own directory). When that layout is in
use and the matrices were cut, a warning (once, muted by --silent, like
the Bun global-dir one) names the file and the directory and says the
environments past the cap were not scanned. An env whose matrix was cut
to nothing is still not looked for under its own name, and a
`hatch-test` matrix still counts as the project's own.

Tests: shared_virtualenvs_dir_reads_a_matrix_up_to_the_cap failed on the
old code (it walked all 248,832 combinations and found the environments
past the cap); matrix_combinations_are_counted_across_the_project and
matrix_names_longer_than_a_directory_name_are_not_built cover the
counting, the cut file and the name bounds.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ends

Vendoring into a pnpm or yarn berry project, and reverting it, parsed the
user's package.json and printed the whole document back. That reflowed any
manifest not already in JSON.stringify form (inline arrays and objects,
`\u` and `\/` escapes, a one-line file, duplicate keys) and changed number
values serde_json does not read exactly: `5.173e28` came back as
`5.173000000000001e+28`, and an integer past u64 became a float. `vendor`
followed by `vendor --revert` was not the identity.

`npm_dir::splice_json` now applies the change as span edits: each member
or array element the new document adds, drops or changes becomes one
splice, and every other byte stays (formatting, escapes, number
spellings, repeated keys, line endings, a BOM, the trailing newline). An
added member copies the indentation and line ending of the member beside
it, and removal is the exact inverse of insertion, so a revert lands on
the pre-vendor bytes. The result is returned only once it parses as the
intended document, so a scanner fault refuses instead of writing.

pnpm (`pnpm.overrides`, and the build approvals carried into
`pnpm.allowBuilds` / `pnpm.onlyBuiltDependencies`) and yarn berry
(`resolutions`) write through it on vendor and on revert. Berry now
records whether it created the `resolutions` table (in the ledger's
existing package.json table flag), so a revert drops a table vendoring
created and leaves one the project already had, even an empty one;
entries recorded earlier keep the previous rule.

Verified by round-trip tests over handwritten manifests (inline arrays,
escapes, exponent and oversized numbers, CRLF, BOM, no trailing newline,
tabs, duplicate keys, one line, uneven indent, pre-existing and empty
override tables): the vendored file is the original plus one inserted
run, and the revert is byte-identical. Both tests fail on the previous
code.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A `${...}` text of a declaration is read under every combination of the
profiles that may move it (up to 255), each read a fresh interpolation
of up to 1,024 property lookups. That was done again for every
declaration, although the answer does not depend on the declaration: a
100 KB pom of 1,000 `<version>${p0}</version>` over a nine-deep property
chain and eight profiles cost 263 million lookups, 28 s in
`socket-patch list`, and every discovery pass and the hosted rewriter's
index pay it.

PomScope now keeps each interpolation it has worked out, keyed by the
text, the active profiles, whether user properties are read and how it
is interpolated, so a text shared by many declarations is read once per
combination. The profiles defining a property are found through an
index rather than by asking every profile. Results are unchanged: the
values the new test expects of every declaration (version, build
version and the eight alternatives with their profiles) are the ones
the old code gave, and the 1,000-declaration pom now takes under
600,000 units of work, where one declaration alone takes 262,911
lookups.

Declarations that each spell a different text share nothing, so one
pom's work is also bounded: a unit per property lookup, per profile
definition read and per 16 bytes of interpolated text kept or handed
out, 4,194,304 in all (a real pom stays under a hundredth of that).
Past it a `${...}` reads as unresolved and a declaration's alternatives
are the text as written under the new MovedBy::PastWorkBound, the same
fail-closed shape as "more than 8 profiles". CLI_CONTRACT.md says so.

Tests count work instead of time:
declarations_sharing_a_text_are_interpolated_once failed on the old code
with 10,516,440 lookups for 40 declarations against a bound of 1,000,000
(its value assertions passed there, which pins the results);
a_pom_past_its_work_bound_reads_as_unresolved and
definitions_list_the_profiles_in_pom_order cover the bound and the
index. formats::maven, crawlers::maven_pom_equivalence_tests,
vex::discover::maven, patch::redirect::maven_index and the
redirect_golden / crawler_maven_e2e suites pass unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The restore answers each pin once, from the registry lookup of whichever
of its lock blocks came last. With the name check added to the lookup, a
hosted source that held a block named `é` ahead of a real one would have
had the real block's checksum and the crates.io source spliced into the
`é` block too, where the old code panicked before writing anything.

The names are now checked when the blocks are collected: a pin with a
block crates.io could not have served is refused before any lookup, and
none of its blocks, manifest pins or registry config is touched.

Test: restore_refuses_the_whole_pin_beside_a_block_named_like_no_crate,
in both block orders (offline, so the other block's lookup cannot
answer). patch::redirect::upstream passes (177 tests).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…dry run

To rebuild a gem's native extension, apply first asked each candidate
Ruby to `Gem::Specification.load` the installed gemspec. That evaluates
the file, a Ruby program a committed vendor/bundle lets the scanned
repository supply. It ran before the record's files were checked against
the installed copy and before the --dry-run test, so
`socket-patch apply --dry-run` executed repository code.

The decision to rebuild now runs nothing. The gemspec is read as text
(it is Specification#to_ruby output): `extensions` as before, and the
require path the lib copies live under with the new parse_require_path.
The order in apply_gem_package_patch is:

1. Preflight from files alone: an extension dir on disk, a require path
   inside the gem, a Ruby to try (one inside the project is still never
   a candidate).
2. A verify-only pass over the record's files. A record that does not
   verify fails here, with nothing run and nothing written.
3. A dry run stops, with a native_extension_rebuilt warning saying apply
   would rebuild (it can no longer tell whether a Ruby here can).
4. The host Ruby is asked where it builds extensions for the gem home
   (`extensions_dir <home>`: RubyGems' own extensions_dir formula,
   default_ext_dir_for included). That loads nothing of the gem. The
   Ruby whose answer is the installed extension dir owns the install.
5. The sources are written and RubyGems' builder runs. Only this step
   loads the gemspec, and it refuses to build unless the loaded spec
   builds into that extension dir, has the require path that was read,
   and declares extensions; a build that exits cleanly without an
   extension dir is undone like a failed one.

Refusals keep their codes and wording. `apply --check` and `vex` never
started Ruby and still do not.

Tests, failing before the change:
- e2e_gem_native_ext_build, with a stand-in ruby on PATH that records
  each run: a dry run starts none and reports the pending rebuild; a
  record whose file matches neither hash starts none (apply and dry
  run); a real run makes exactly `extensions_dir` then `build`.
- the_installed_gemspec_is_not_evaluated_before_the_record_verifies
  (real Ruby 3.4, #[ignore] capstone): a side effect planted in the
  installed gemspec does not fire on a dry run or for a mismatched
  copy, and does once the build runs. Before: "a dry run evaluated the
  gemspec".
- patch::gem_ext: parse_require_path, find_ruby asking only for the
  extensions dir, the preflight's file-only refusals.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`.socket/vendor/.commit-journal.json` is the roll-forward log of the
vendored group commit, and every command that takes the apply lock
replays it. It is also a file in the working tree, so a checkout can
carry one that no crash wrote: a pull request from a fork can commit it,
with its payload in base64. The replay accepted any project path outside
`.socket/`, printed nothing when it rolled files forward, and ran under
`--dry-run` too. A hand-written journal therefore made `socket-patch
apply` (also `apply --dry-run`) create `.git/hooks/post-checkout`, a
place no commit can reach, without a line of output (PE-13), and `vendor
--dry-run` finished a pending commit before previewing (CS-D11).

Three things now hold.

A replay writes only what a commit could have journaled. The captured
set (`is_captured`), which is exactly what a journal may name, no longer
includes a path with a git-directory component. No backend writes there,
so a genuine journal from this or an older 5.x CLI never names one. The
component is matched the way git guards its own tree (`is_ntfs_dotgit`,
`is_hfs_dotgit`): ASCII case folded, the code points HFS+ ignores
skipped, NTFS trailing dots/spaces and stream suffixes cut, and the
`git~1` short name. The `.socket` component the captured set already
excluded is matched the same way, so `.SOCKET/manifest.json` cannot
reach the manifest or an artifact where the filesystem folds case.
Absolute paths, `..` and linked levels were already refused; the lexical
rules run before any level is probed, so a forged entry is a refusal
(journal set aside, nothing applied, not even its plausible entries)
rather than an I/O error. The names cannot be
narrowed to an allow-list: the backends rewrite files the project names
(`-r` requirement includes of any name at any depth, every workspace
member's manifest, JVM build files).

A replay is never silent. The lock acquire prints what it did on stderr
in every output mode, naming each file it rolled forward, and the guard
carries the same sentence (`LockGuard::commit_recovery`) so the command
that took the lock adds it to its `--json` envelope as a
`vendor_commit_recovered` warning. The existing set-aside report rides
the same channel as `vendor_commit_set_aside`. Journaled paths are
printed without control or bidi characters.

A dry run replays nothing. `acquire_dry_run` takes the lock but leaves a
pending journal alone and fails the acquire with
`LockError::CommitPending`; the CLI's one lock wrapper now takes the
run's `--dry-run`, so every lock site states it, and the refusal is the
new top-level error `vendor_commit_pending` (exit 1), naming the journal
and every file it covers. It stops rather than previews because the
files may be half replaced: the same reason a replay that fails on I/O
already fails the acquire, and `vendor` plans its eject off those
lockfiles (#1782). Setting a journal aside is a rename, so a dry run
does not do that either.

Verified: the forged-journal unit test in group_commit and the three
new CLI tests in vendor_group_commit_e2e were run against the previous
code and failed there (the forged journal was rolled forward into
`.git/hooks/`; `vendor --dry-run` exited 0 having replayed; no warning
was printed). They pass now, with new unit tests in apply_lock and
lock_cli and a scan test for the new API and report. A genuine journal
still rolls forward under vendor, vendor --revert, apply, rollback,
repair, remove and scan, and each reports it. One existing test
triggered the set-aside through a dry run; it now asserts that the dry
run refuses and uses a wet command. CLI_CONTRACT.md documents the path
rule, the report, the two warning codes and the new error code.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The berry revert restored and wrote yarn.lock, and only then read and
parsed package.json. A manifest that did not parse at revert time (a
merge-conflict marker) failed the revert with the lock already rewritten
to the registry entry while `resolutions` still pointed at the vendored
tarball, a pair the next plain `yarn install` re-resolves, and the error
did not say the lock had changed.

The revert now reads and parses the manifest first, computes both new
files in memory, and writes only once both are known good (lock first,
as before). An unparseable or uneditable manifest fails with "nothing
was reverted" and leaves both files as they were; a missing manifest is
still a warning.

The existing corrupt-manifest test asserted the partial restore; it now
asserts both files are untouched and that the same revert converges
once the manifest is repaired. It fails on the previous code.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`apply` and `apply --check` decide which purls `socket-patch vendor`
owns from `.socket/vendor/state.json`. When the file could not be read
they treated every manifest patch as an in-place patch and said nothing,
where `rollback` and `remove` report `vendor_state_unreadable` (CA-D09).
`list --json` left the vendored patches out of a `success` envelope with
only a stderr line as the trace (CA-D10).

Both now carry the `vendor_state_unreadable` warning: in `warnings[]`
under `--json`, as a `Warning:` line on stderr otherwise. apply still
patches and exits as before, so an unattended run on an agent-only
project is not failed by a ledger it never needed; the run just no
longer hides that vendor ownership was not consulted.

`apply_warns_when_the_vendor_ledger_is_unreadable` and the new `--json`
leg of `silent_gates_the_malformed_vendor_ledger_warning_via_binary`
failed before the change. The apply, cli_parse_list and
manifest_path_ledger_root binaries and the CLI unit tests pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Folding duplicate package records re-hoists the lock's dependency graph
with a model of Bun's hoister. A graph that keeps nesting two packages
of one name inside each other (root -> m -> n -> m' -> n' -> m), or one
that nests two trees under every tree, made that hoist allocate without
end: a 1.8 KB crafted bun.lockb took `vendor` to 2.7 GB in 1.6 s.

The hoist now stops once it has placed more dependencies than the lock
itself records (plus one unwritten optional peer per edge) or has spent
64 steps per recorded entry, edge and package; a hoist that reproduces
the lock's own trees never does either. It looks names up in an index
and walks the trees in a loop, so a step is constant work and a deep
tree cannot overflow the stack. Running out is `None`, which already
means "these records cannot be folded": the records are rewired one by
one with the existing warning, as for any lock whose trees this model
does not reproduce (the 0.1.x writers'), so the fold is declined rather
than the lock refused.

The same review of sizes read from the file found one more class: every
string reference is checked against the string buffer, but nothing
bounded their sum, so records that all name one long string were copied
once per record (packages, the metadata hash, the hoist graph's names,
the rebuilt legacy string buffer), and overlapping dependency slices
listed every string field once per package. Each pass now spends from a
budget of eight times the file size and refuses the lock beyond it.

Verified: the two new hoist tests ran 2 CPU-seconds to 850 MB before the
change (stopped by a CPU limit) and finish in milliseconds after it;
`hoist_reproduces_every_captured_writers_trees` still reproduces the
trees of every fixture lock byte for byte, using at most 1.2 steps per
entry of the 64 allowed; the string-budget test fails with the guards
taken out.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`rollback`, `remove` and the withdrawn-pin sweep restore a hosted pin by
rewriting every file that wires it (pom.xml + .mvn/maven.config + the
trusted checksums, package.json + yarn.lock, .npmrc + package-lock.json).
The flush checked each file for a symlink right before writing it and
never put an earlier file back, so a refusal or an I/O error on file N
left files 1..N-1 rewritten. On a Maven pin with a symlinked pom.xml,
`rollback` deleted .mvn/maven.config and the trusted checksums, left the
pin live, printed "Restored ..." and counted `rolledBack: 1`, and
`--dry-run` previewed a success (audit HE-D1, CS-D3).

The flush is now two steps (patch/redirect/staged.rs):

* `preflight` checks every staged file before the first is written: its
  real path is inside the repository root, it is a regular file or
  absent, it still holds the bytes the restore read, and its directory
  takes the write (skipped for a file an open group commit captures). A
  dry run runs it too, so the preview reports the refusal.
* `flush` writes, and when a write fails it puts the files already
  written back from the bytes preflight read, newest first (a deleted
  file with its mode). Emptied directories are pruned only once every
  file landed, so a put-back still finds them. When a put-back fails as
  well, the error names each such file and the state it is left in.

The undo is held in memory instead of the vendored group commit's
journal: that journal lives in .socket/vendor/, which a hosted project
does not have and a restore must not create, and a group captures neither
the Gradle wiring under .socket/gradle/ nor files reached through `..`.
A process killed between two writes is therefore still not covered.

Reporting: a pin whose files were not written is the new
`PinStatus::Unwritten`, never `Restored`, and `RestoreOutcome` lists the
files a failed flush could not put back. `run_hosted_leg` prints no
"Restored" line for such a pin and keeps it out of `reverted`:

* rollback emits only the artifact-level `hosted_write_failed` event
  (`status: "error"` / `rollback_failed` when nothing else was rolled
  back, where the false `rolledBack` used to make it `partialFailure`),
  and `hosted.editedFiles` counts only files left changed;
* `--dry-run` reports "writing the restored lockfiles would fail", exit 1;
* the withdrawn sweep records one `redirect_withdrawn_restore_failed`
  warning naming the pins it kept and no `rolledBack` event; exit and
  status stay as documented. A restore left half applied stops the scan
  with `lockfile_write_failed` before the rewrite runs over it.

`failpoint::fails` adds a `~fail` form of SOCKET_PATCH_FAILPOINT, so a
test of the built binary can fail the n-th write (`hosted_restore_write`)
or put-back (`hosted_restore_put_back`); release builds compile it out
like `hit`.

Verified: against main's flush (with the same test-only injection hook
in its write loop) the staged.rs restore tests for a symlinked or
unwritable file 2 of 3 and for a write failure on file 2 fail with file 1
already deleted and the pin `Restored`. The new rollback and
in_process_scan_withdrawn tests fail on main too: the symlink and
read-only cases because the pin is reported `rolledBack` / "Restored",
the failpoint cases only because main has no failpoint to fail a write
with. All pass here, together with the put-back, failed put-back and
drift unit tests; cargo test -p socket-patch-core --lib
patch::redirect, hosted::, vendor::, the redirect_golden /
upstream_restore_golden / hosted_inventory suites, and the CLI rollback,
remove, in_process_rollback_hosted, in_process_scan_withdrawn,
in_process_redirect*, vendor_eject* and mode_migration_pypi suites pass.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`scan --mode hosted` puts the files it already wrote back when a later
write fails, and then reported "(nothing was changed)" whatever became of
that put-back: `put_back` (the Gradle files written straight to disk)
discarded its errors with `let _ =`, and the unjournaled group commit
discarded the error of its `restore` (audit T30). A project left with one
lock rewritten and the next not was reported as untouched.

* `group_commit::restore` now tries every file instead of stopping at the
  first failure and returns one line per file it could not put back. A
  journaled commit still keeps its journal for the next locked command;
  an unjournaled one, which has no journal to finish from, carries the
  lines in its error (`group_commit::left_replaced`).
* `put_back` returns the same lines for the direct writes, and only puts
  back files that reached the disk (the file whose own write failed never
  changed).
* `write_failure` words the error: "(nothing was changed)" only when
  every file was put back, otherwise each file with the state it is left
  in ("is left with its new content" / "created" / "deleted") and "every
  other file is unchanged".

Verified: utils::group_commit::tests::
a_failed_unjournaled_commit_names_the_files_it_could_not_put_back fails
on main's `restore` (the error does not mention the replaced file) and
passes here; commands::scan::hosted::tests::put_back_names_each_file_it_
could_not_put_back and in_process_redirect's
a_failed_hosted_write_says_which_files_it_could_not_put_back (a second
lock in a read-only directory, with and without the new
`group_commit_restore` failpoint) pass, as do the group_commit unit
tests, vendor_group_commit_e2e, vendor_crash_safety_e2e and the
in_process_redirect suite.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The hosted trust edit splits pnpm-workspace.yaml at `\n`, inserts its
lines and joins with `\n`. The file's own lines keep their `\r`, the
inserted ones had none, so a CRLF file ended
`...\r\ntrustLockfile: true\n` (audit HE-D3); the scaffold spliced into a
CRLF file with no keys was three LF lines.

`formats::pnpm::workspace::splice_lines` now inserts the lines in the
file's own style (`utils::line_endings::terminator`): in a CRLF file
each inserted line that another line follows carries the `\r`, and so
does a last line that had no ending until the lines followed it. No
other line changes, and an LF file is spliced exactly as before.

The restore keeps up: `strip_keyless_splice` matches the scaffold in
either line ending (and takes back the `\r` it gave an unterminated last
line), so `rollback` still returns a CRLF keyless file byte for byte,
and the file hosted mode created is recognised in either line ending,
as the engine's `is_trust_scaffold` already does, so a `core.autocrlf`
checkout of it is removed like its LF twin instead of being kept with
`pnpm_trust_lockfile_left`.

Verified: hosted::guidance::tests::
plan_workspace_trust_keeps_crlf_line_endings fails on main
(`trustLockfile: true\n` after CRLF lines) and passes here;
formats::pnpm::workspace tests and in_process_redirect_pnpm's
hosted_trust_edit_keeps_a_crlf_workspace_yaml_crlf (scan, then rollback)
pass, as do the hosted::, formats::pnpm and vendor:: unit tests and the
hosted_memory_engine / hosted_memory_parity /
in_process_vendor_pnpm_takeover suites.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A bun.lockb entry of `.socket/vendor/state.json` can carry the whole
original lock (`layout_original`), and `vendor --revert` parses it and
rebuilds its legacy string buffer to compare it with the live lock. The
live lock is checked for an unknown trailer before any edit, the ledger's
copy was not: one cut one to seven bytes into an extension tag parses as
a lock with an unknown trailer, and the extension walk then sliced eight
bytes past its end (exit 101).

`normalize_production_pool` now checks for the unknown trailer itself,
so no caller can reach the walk without it, and the walk stops where
`parse` does. A snapshot that cannot be read, normalized or hashed is
reported as what it is ("the vendor ledger's snapshot of the original
lockfile (layout_original) cannot be restored from: ..."); the revert
leaves that record alone as drift and keeps the lock, the artifact and
the ledger entry.

Verified: the new codec test panicked with "range end index 1468 out of
range for slice of length 1461" before the change; it and the revert
test (dry run and wet) pass after it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Hosted mode wrote a reference grant's fields into the project's files as
the patch API sent them. A grant is whatever answered (Socket's API, a
--api-url server, a proxy in between) and nothing checked it, so a
quote, newline, `$`, `<` or `&` in a URL, digest or coordinate changed
the structure of the file it landed in while the run reported success:

- yarn.lock (v1): an integrity.sha1 or sha512 holding `"` and a newline
  added a field to the block (the tarball check passed, since an SRI
  string is read up to its first whitespace);
- Gemfile.lock: a gemChecksumSha256 holding a newline added a CHECKSUMS
  entry for a gem the project never locked;
- pom.xml: markup in mavenSuffixedVersion or indexUrl landed verbatim (a
  `system`-scoped path, a pom no longer well formed), and a group id
  holding `</dependency>` panicked the run (exit 101);
- composer.lock, requirements.txt and the Gradle / Mill / scala-cli
  snippets took the same values raw;
- a plain http:// artifact URL on any host was committed without a word,
  so every later install sends the grant token in clear text.

build_candidates is where a grant becomes a rewrite candidate, for the
disk scan and the in-memory engine alike. It now checks every field of
the override it builds (patch::redirect::grant::check) before anything
is planned for it, so no takeover, artifact fetch or rewriter sees a
grant that fails:

- the patch uuid is canonical;
- the artifact URL and a registry override's index URL are https URLs of
  plain URL characters (no whitespace, control character, quote,
  backslash, `$`, `{}`, `<>`, `&`, `#`, `;`, `|`, `^`). Plain http is
  taken only from a loopback address or the --patch-server-url origin
  (origin::on_configured_origin, the rule discovery already applies);
- sha1 is 40 hex digits, sha256 64, sha512 one `sha512-<base64>` token,
  the Go hashes `h1:` hashes, the berry checksum `<key>/<sha512 hex>`,
  the cargo and gem checksums 64 hex digits. A Maven grant's sha256
  digests are only held to the digest alphabet: the pom, Gradle and sbt
  planners read `sha256-` / `sha256:` spellings and write no checksum
  pin for anything else, which stays as documented;
- names, versions and every registry-override identifier are in their
  ecosystem's coordinate grammar, through the existing guards
  (is_safe_npm_name, is_plain_gem_token, is_plain_nuget_token,
  safe_coordinates, the path_safety segments) plus a character set;
- an absent or empty optional field is not checked (the rewriters read
  an empty string as "not served").

A grant that fails is skipped under the selection's purl with
redirect_grant_malformed (the detail names the field, never its value)
or redirect_grant_insecure_url (the detail names the host and the
override), and warned with the same code and detail. Nothing is written
for it, the run continues, and the exit code is unchanged. CLI_CONTRACT
documents the gate and both codes.

Tests. scan/hosted_grant_validation.rs drives each sink through the
built binary (`scan` for package-lock.json and yarn.lock, `get <uuid>`
for Gemfile.lock, pom.xml, composer.lock and requirements.txt) with the
payloads the audit reproduced; all six failed before this change (the
grant was written, or the run panicked) and now assert the skip, the
warning and a byte-identical project tree. The grammar is unit-tested in
grant.rs, the engine's skip and warning in engine.rs, and the in-memory
path in hosted_memory_engine.rs. Every golden fixture's overrides.json
passes the gate unchanged.

Fixtures. The CLI tests' `patch.test` origin is now https: its grants
were plain http on a host that is neither loopback nor, in most tests,
configured. The two golang e2e suites' grants named their `file://`
GOPROXY as artifact and index URL; they now name the patch server, as
production's do (go resolves the socket module by path through GOPROXY
either way). The engine unit tests' grants use canonical uuids and
64-digit sha256s.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Defense in depth behind the grant gate: the rewriters that pass a
grant's value through a regex replacement or author XML from it no
longer let the value mean anything but itself.

- composer.lock: the dist `url` and `shasum` went in through
  `Regex::replace` with a template (`${1}<url>${2}`), so a `$` in the
  value was read as a capture reference: `…zip?x=${1}y` wrote the
  matched `"url": "` prefix into the middle of the URL and broke the
  JSON. They are now spliced by a closure that reads only the two
  captured delimiters.
- Gemfile.lock: an entry added under an empty CHECKSUMS header went in
  through the same kind of template; `${1}` in a checksum expanded to
  the header's captured line ending. Now a closure, with the same
  output for every well-formed value (LF and CRLF pinned).
- Cargo.toml: the `registry = "socket-patch-<uuid>"` replacements use
  `regex::NoExpand` (the uuid is already checked canonical there).
- pom.xml: the Maven index escapes `&`, `<` and `>` in the element text
  it authors (repository id and url, the managed dependency's
  coordinates, a rewritten version). A value can no longer close the
  element it sits in, so `authored_element` always finds the close tag
  it wrote: a group id holding `</dependency>` used to end the authored
  element early and panic at `.expect("authored")`. For a value without
  those three characters, which is every value the gate lets through,
  the bytes written are the same.

Each has a unit test that failed before: the composer and gem values
are written verbatim, and the three pom payloads leave a pom the
rewriter still reads, with no `<scope>`, `<systemPath>` or comment of
the value's making.

The seeded composer sweep pinned the expansion: one of its URLs is
`…/with$1dollar.zip`, and the oracle it was blessed against dropped
`$1dollar`. composer_lock_rewrite.golden is re-blessed for that. Run
side by side over the 3000 seeds, the template and the closure differ on
410 cases, every one of them a case with that URL among its overrides;
no input digest changes.

The redirect, sbt and upstream-restore golden suites pass unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…e the rename

In-place patching wrote one file at a time: stage, fsync, rename, fsync
the directory, and only then chown and chmod the new inode. Three
defects came out of that shape.

An interrupt between the rename and the chmod left the patched bytes
under the stage's umask mode for good: a later apply sees the file as
already patched and rollback snapshots the wrong mode. A 0755 script
came back 0644 in 11 of 12 SIGINT runs on a 150-file package. A chmod
the filesystem refuses was reported as a failure of a file whose bytes
were already committed.

A failure on file N (a blob that does not hold its hash's bytes, EACCES,
ENOSPC, a refused rename) left files 1..N-1 patched, and the error named
neither the file nor the blob. Rollback had the same loop.

Each file cost two device flushes (F_FULLFSYNC on macOS): 17 s to apply
a 2,000-file package.

The engine now makes a package's changes through one PackageWrite:

- Every file is staged before the first is renamed: blob read and
  hash-checked, parent directory relaxed or created, stage written with
  the file's final mode and owner (utils::fs::stage_blocking, which the
  mode-preserving writer for project files now shares; the engine's own
  restore_file_permissions is gone). The rename is the only step that
  changes what a path holds. A stage that already has the wanted bits is
  not chmod-ed, so a filesystem without permissions no longer fails.
- Before the first rename every file about to be replaced gets a second
  name (a hard link; a private copy where the filesystem refuses one).
  A rename that fails puts the files already replaced back by renaming
  those names over them, so the very inodes return. Deletes (rollback of
  a patch-added file) are renames aside until the write is closed.
- A failure names the file: `Cannot apply patch: <file> - <cause>`,
  `Cannot roll back: <file> - <cause>`; a corrupt blob is named with the
  remedy. On failure `files_patched` / `files_rolled_back` are empty.
- Stages are fsynced as they are written; the device cache is flushed
  once before the first rename and once, with the directory syncs, when
  the write is closed. A renamed file is still never torn after a crash.

What an interruption can still leave is some files renamed and the rest
not, since N renames are not one; the renames now run back to back and
the next apply or rollback finishes the package. It also sweeps the
stages and second names an interrupted run left beside the patch's files
once they are ten minutes old (longer than any write holds one, so a run
of another project in a shared cache is never swept from under itself).

Verified on macOS (APFS). With the pre-change binary and a temporary
failpoint between its rename and chmod, the new
interrupt_after_the_rename_keeps_the_permission_bits fails (mode 0644);
a_corrupt_blob_for_a_later_file_writes_nothing fails (first file left
patched); rollback with a corrupt before-blob for the second of three
files restores the first and fails. All pass now, with
a_rename_failing_midway_leaves_the_package_original and its rollback
twin (failpoint `patch_file_commit@2~error`, a new error-injecting
failpoint action). 24 SIGINT runs over a 2,000-file package left no
wrong mode (the old binary: 11 of 12 on 150 files, all 12 half patched):
18 fully original, 5 finished, 1 half patched, which the next apply
completed.

Timing on that package, apply / rollback, three runs each on a quiet
machine: 16.4-18.8 s / 17.5-18.2 s before, 1.2-1.6 s / 2.0-2.8 s after.
With other builds saturating the disk both degrade (one round: 23.6 s /
25.9 s before, 9.5 s / 25.8 s after): the per-file fsync remains, only
the two device flushes per file are gone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every command that rewrites `.socket/manifest.json` (remove, rollback,
get, scan) serialized the schema's own fields and nothing else, through
the writer that stages with umask defaults. A top-level or per-record
field this version does not know (one a newer CLI or the TS tool wrote)
was dropped by the first rewrite, without a word, and a private 0600
manifest came back 0644 (owned by root after a sudo run).

write_manifest now reads the manifest it is about to replace and carries
its unknown top-level fields, and those of every record that is still
the same patch (same uuid), into the new document, at the positions the
file had them. A removed record takes its fields along and a record
another patch replaced does not inherit them. A manifest without such
fields is serialized exactly as before, so committed manifests keep
their bytes. The write goes through the mode-preserving writer.

The fields are merged at the write boundary rather than flattened into
PatchManifest / PatchRecord: both are built as struct literals in some
seventy files, and a new field would have to be added to each.

Not changed: the manifest is still written without a trailing newline.
Adding one would change the bytes of every committed manifest.

Reproduced with the pre-change binary: `remove` of one of two records
dropped `schemaVersion`, `futureTopLevel` and the other record's
`futureRecordField`, and left the 0600 file at 0644. The new
test_write_manifest_* tests cover an unchanged rewrite (byte-identical,
unknown fields in place), a removed and a replaced record, the plain
manifest's byte stability and the mode; test_known_keys_match_the_schema
fails if a schema field is added without its key.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`apply`, `rollback` and `remove` built the API client before doing
anything else, and `repair` built one afterwards just to address its
telemetry event. Building the client reads the token, prints the
client's advisories and, with a token and no org, asks the API for the
org (`GET /v0/organizations`). So a run that fetched nothing still:

- printed "No SOCKET_API_TOKEN set ... using the public patch API proxy"
  (or "Could not determine your organization ...") although no API was
  used, and reported `api_auth_fallback` in `--json`;
- waited on the org lookup. A no-op `apply` with a token and no `--org`
  (the run CI makes after every install) took 12 s against an endpoint
  that drops packets and 65 s against one that accepts and never
  answers, with its output held back the whole time.

The run's client is now a cell (`RunApiClient`, the one embedded `--vex`
already used, moved to `args.rs`) that the first step that fetches
fills through `GlobalArgs::api_client`: the staging download in `apply`,
the before-blob download in `rollback` / `remove`, the Maven jar
service, `repair`'s download pass. `get` and `scan --mode agent` seed it
with their own client for the nested apply. A run that finds everything
on disk makes no API request, prints no advisory and reports no
fallback.

Telemetry follows the client's route once one exists
(`GlobalArgs::telemetry_auth_for`) and otherwise takes it from the
credentials alone, as `list` always did. A token with no configured org
therefore reports on the anonymous route when the run fetched nothing:
the org is never looked up only to address an event.

Not lazy: an `apply` with a Maven patch in scope still builds the client
up front, because the jar-service config it hands to core holds one.

Tests, each failing before this change:
- apply/rollback/remove/repair with a token and no org and everything
  cached: the mock sees only `POST /patch/telemetry` (before:
  `GET /v0/organizations`, then the org telemetry route).
- a tokenless apply / rollback that fetches nothing prints no proxy
  advisory; the one that downloads prints it once.
- an apply that fetches nothing never resolves the org and reports no
  `api_auth_fallback`; the one that downloads still does (#648).
- `stage_patch_sources` leaves the cell empty unless it downloads.

CA-P01, CA-U07.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The first /code-review pass did not read the vendor backends, VEX
discovery, the hosted rewriters, the group commit or the JVM code. A second
pass over those returned four candidates; three are fixed here.

- The hosted gem rewriter could append `source:` after an options hash.
  A trailing non-literal argument (`gem "rack", OPTS`) is taken for a
  version requirement when the lock shows one requirement per positional
  argument. That count was wrong in two cases: an array literal is several
  requirements in one argument (`gem "rack", [">= 2.0", "< 3"], OPTS`), and
  a `gemspec` directive lets bundler fold the gemspec's own requirement
  into the lock entry. In both, `OPTS` may be the options hash, and with
  an option after it bundler reads it as a version requirement: the
  Gemfile no longer loads. Such a declaration is now refused
  (`redirect_gem_unrecognized_declaration`) unless every other positional
  is a single quoted string and the manifest has no `gemspec`.
- A revert decided whether a left-alone record could be discarded by
  searching the warning's text for "was re-resolved since vendoring".
  Other warnings quote ledger text (an unknown wiring kind and its key),
  so a committed ledger could make an unknown-kind record, which must keep
  the artifact, read as a re-resolved one. The fact is now a flag set only
  where that warning is built.
- The hosted takeover took any drift warning for a revert that kept its
  artifact. A revert that finished because the dependency had moved on
  still reports which entries moved, so the takeover rolled it back and
  refused. It now asks whether the revert finished.

Not changed: each `bun.lockb` ledger record carries the pre-vendor lock
when the lock needed normalizing. Storing it once needs a ledger-level
field older CLIs would not read; it is listed for the owner.

Tests: three new refusal cases in
gemfile_declarations_that_cannot_take_the_option_are_refused_with_a_remedy
(array literal, %w, gemspec; they fail without the fix). Suites: core
vendor::, patch::redirect, formats::gem (4017); CLI in_process_vendor,
vendor, scan_vendor_e2e, in_process_redirect,
in_process_vendor_pnpm_takeover, mode_migration_npm,
command_module_layering, e2e_redirect_gem_stale_install; real Bundler
e2e_redirect_gem_build --include-ignored (42). fmt and clippy clean.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`vex` read a Bundler lock as wiring by itself: a `GEM` section on the
patch registry made every spec under it `not_affected (redirected)`, a
`PATH` section under `.socket/vendor/gem/` made it `(vendored)`. The
Gemfile was read only to split a merged multi-remote section.

Bundler does not work that way. It takes a gem from the source its Gemfile
declaration names and treats a lock that says otherwise as out of date.
Measured with a local index on Bundler 1.17.2, 2.1.4, 2.4.22, 2.5.23 and
4.0.15, on fresh checkouts of each pair:

- Gemfile reverted to `gem "x", "1.0.0"`, lock still on the patch
  registry (with or without the `!` in DEPENDENCIES): a frozen install
  exits 16 ("the list of sources changed"), an unfrozen one rewrites the
  lock to the upstream lock and installs the upstream gem;
- the same for a gem only another gem depends on, whose Socket section
  the Gemfile never names;
- the same for a vendored `PATH` section whose `path:` the Gemfile lost
  (2.4.22 crashes the frozen install instead of exiting 16);
- the pairs both writers leave install the patched gem, frozen and not.

So a Gemfile revert, or a merge that takes the other side of the Gemfile,
left a statement over an install of the unpatched gem.

A spec under a Socket-wired section is now a reference only when the
manifest Bundler loads beside that lock (`Gemfile` for `Gemfile.lock`,
`gems.rb` for `gems.locked`) gives the gem that source: every `gem`
declaration of it carries `source: "<the section's remote>"` or sits in a
`source "<remote>" do` block (hosted), or carries `path:` naming the
section's vendored directory (vendored), and there is one
(`ManifestSources`). The declarations are read with the reader the hosted
rewriter writes them with (`formats::gem::declarations`, block nesting
from `formats::gem::ruby`), so every form the writers leave passes: the
appended option in any call shape, the line added for a transitive gem,
the 5.0 / 5.1 block (nested in a user's block too), `vendor`'s `path:`
with the options it keeps after it (a `**opts` splat included) and its
managed block, LF or CRLF. A second source-selecting option beside ours
does not (`git:` / `path:` win over `source:` in Bundler), nor does a
source that is not a plain string, nor a lock with no manifest beside it.

A hosted spec the manifest does not confirm is still one gem version's
pin in the lock, so it is kept in `Discovery::shadowed`: never attested,
no ledger claim, still what `rollback` / `remove` / the vendored takeover
unwind. A vendored one is no reference; its ledger entry reads
`vendor_unwired` and the `patched_ref_unattributable` diagnostic keeps it
from the prune GC. The diagnostic names the gem, what the Gemfile says
instead, and both ways out (re-run the scan or vendor, or `bundle lock`).

The confirming read is quiet and recognizes nothing, so the ledger
fallback for a Gemfile-only pin answers as before; a merged section's
cross-check keeps its guarded read. The lock twin Bundler ignores is
explained without the confirmation (its manifest may be gone).

Fixtures that staged a wired lock with no manifest now stage the pair:
eight gem discovery unit tests, the cross-ecosystem `--no-verify`
capstone and the detached vendor matrix. No discovery golden changed.

Tests: a_hosted_section_the_manifest_does_not_name_is_not_a_ref and its
vendored twin (both fail with the confirmation off), the two positive
form matrices, and the CLI's
a_lock_section_the_manifest_does_not_name_attests_nothing and
hosted_source_option_pair_attests_in_every_era. The real-Bundler
capstones gain a manifest-only-revert leg each (the hosted one proves the
frozen refusal, the rewritten lock and the upstream bytes; the vendored
one the refusal and the dropped section) and pass on all five Bundler
lines above.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
For a dependency with no literal `<version>` in the root pom, hosted scan
pins the patched version in the root's `<dependencyManagement>`
(`redirect_maven_dep_management_added`), and `vex` attested that pin from
the root pom alone. Maven applies a managed version only to a dependency
that declares none, and a pom inherits its parents' `<dependencies>`, so a
parent pom that declares the package with a version decides what the
build resolves. Discovery read no parent pom.

Measured with real Maven 3.9.16 (`dependency:list`, slf4j-api from
Central, the Socket repository unreachable), the root holding only the
managed pin:

- the parent, the parent's parent, or an active profile of the parent
  declares it at 1.7.36: the build lists 1.7.36 and never asks for the
  patched version;
- the parent declares it with no version, or re-declares it with none
  over a literal above, or the root declares it itself with no version
  over the parent's literal, or nothing declares it (transitive): Maven
  asks for the patched version.

Discovery now follows `<parent>` through the checkout for a pin of that
shape (`Ancestors`: `<relativePath>`, default `../pom.xml`, a file or its
directory, taken only when the pom there is the parent the reference
names; read through the view, so never outside the git checkout):

- a parent pom, one above it, or a profile of either that declares the
  package with a version keeps the ref (rollback, remove and list still
  find it) and marks it unattested, `MavenParentDeclares`, omitted as
  `vex_maven_parent_declares` with the parent pom named and the remedy
  (declare the package in the root pom with no version);
- the nearest parent declaring it with no version, or a chain read to a
  pom with no `<parent>`, changes nothing;
- a parent that cannot be read (`<relativePath/>` or no pom at the
  relative path, so Maven resolves it from a repository; another
  project's pom there; one that does not parse; one outside the checkout)
  may declare it either way. The ref then carries no pin and requires
  one, the shape Gradle refs use, so it never takes the lockfile basis:
  `package_not_found` until a build has resolved the patched version,
  attested from that installed copy afterwards.

A pin on the root pom's own declaration, or one governing a declaration
the root makes with no version, is not weighed against anything. A
coordinate a parent spells through a property may name the package, so it
counts.

This costs statements: a project whose parent is not in the checkout
(Spring Initializr writes `<relativePath/>`) loses the pre-build statement
for a managed-only pin and gets it back after a build. No committed
fixture or capstone has a `<parent>`, so no golden changed.

Tests: a_managed_pin_a_parent_pom_overrides_is_not_attested,
a_managed_pin_under_an_unread_parent_needs_the_installed_copy (both fail
with the check off) and a_managed_pin_no_parent_pom_overrides_is_attested
(core), and the CLI's
maven_hosted_managed_pin_is_weighed_against_the_parent_pom.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…m remedy that works

Two loose ends of the fail-closed cargo and gem rules.

The scan command's takeover classifier test staged the hosted side of a
vendored-to-hosted cargo takeover as a Socket-sourced `Cargo.lock` and a
registry definition, with no `Cargo.toml`. Hosted scan never leaves that:
the manifest pin is what makes cargo resolve the crate from the lock's
source, and a lock entry no declaration routes is now withheld. The
fixture writes the pin, and the test passes as it was written
(`cargo_takeover_classifies_hosted_when_the_lock_points_at_the_socket_registry`
was the one CLI unit test the cargo change turned red).

The diagnostic for a Bundler lock section the Gemfile does not name told
the user to re-run the scan. That does not work while the lock's
DEPENDENCIES entry is still source-pinned: the hosted rewriter skips such
a gem (`redirect_gem_source_option`). The remedy that does work, on every
Bundler line measured, is an unfrozen `bundle install`, which makes the
lock follow the Gemfile, and then the scan. The diagnostic and
docs/ecosystems.md say that now, and that `rollback` / `remove` still see
the lock section (checked with the built binary: `list` shows the pin,
`rollback --offline` reaches the restore and stops only for want of the
registry).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
CHANGELOG.md is written only when a release is cut, by the release
agent, from the merged PRs and the code (AGENTS.md). This branch is not
a release branch, so its 606 added changelog lines come out here; the
same details are in the PR description and the commit messages.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011f9aeSCJbrmVGsRGmsy7w4
Four entries under `### Changed` for the branch merged in the previous
commit (Cargo, Composer, RubyGems, Maven), written into the 5.1.0 section
at the owner's explicit request for this pull request, like the others.
Each states what `vex` no longer attests and the code it reports.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A repository routine restored CHANGELOG.md on this branch (bf8925d), as
AGENTS.md has it for any branch that is not a release branch. The 5.1.0
notes for these changes are kept, unchanged, on the release branch
`release/v5.1.0-review-notes`, which the rule allows to edit the file.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 11, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Ready for review (burn-down agent)


Generated by Claude Code

A trailing `\` carried a hosted gem's declaration over any number of
lines: `last` kept the backslash after its newline, so a blank or
comment-only line that ends the statement in Ruby did not end it here.
`gem 'rack', '~> 2.2' \` followed by a blank line and `gem 'puma'` read
as one call, and the Gemfile edit put `source:` on puma while the lock
pinned rack. The backslash now carries the call on one line only; a
trailing comma still carries it over blank and comment lines.

A bare `rollback` treated a project whose mode a bare `scan` cannot tell
(agent-mode patches beside a vendor ledger) as one a scan looks below,
so it rolled back the patched projects below `--cwd` while the scan
stops with `mode_ambiguous` and touches nothing. Only a hosted or
vendored project now sends rollback below `--cwd`.

Both come with tests that fail without the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@mikolalysenko Mikola Lysenko (mikolalysenko) removed the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 11, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review (merge steward): two fixes pushed in f16ca61; brief to follow when CI is green

I reviewed this PR in five parts with parallel readers, focusing on the code the PR's own review only searched for panics: the Maven reactor's second half, the vlt/uv/yarn/legacy-pnpm/bun vendor backends, formats/maven, the uv/cargo/nuget restores, child-process env scrubbing, the commit journal, grant validation, telemetry, the API client, monorepo discovery and rollback scope, apply/rollback atomicity, and the gem hosted rewriter and refresh.

Fixed here (each with a test that fails without the fix):

  • formats/gem/declarations.rs: a trailing \ carried a gem call over any number of lines. With gem 'rack', '~> 2.2' \, then a blank or comment line, then gem 'puma', the two lines were read as one call. The hosted edit then wrote source: onto puma while the lock pinned rack. Ruby itself reads them as two calls. A \ now continues the call onto the next line only.
  • commands/rollback.rs / commands/mod.rs: when a bare scan can't tell the project's mode (agent-mode patches beside a vendor ledger), it stops with mode_ambiguous and changes nothing. A bare rollback in the same project still rolled back every patched project below --cwd. Rollback now goes below --cwd only for a hosted or vendored project.

Not blocking, for the owner's follow-up list:

  1. patch/redirect/upstream/cargo.rs:181-205: this code is already on main; this PR only adds a comment there. Restore panics when two Socket blocks of one name and version, each with a crates.io twin, are the last two blocks of a lock that has no [metadata] section. The last block's range starts one byte earlier and overlaps the previous block's range, so replace_range goes past the end of the string. That needs a hand-merged lock or two hosted generations of the same version. The new comment at :197 ("equal ranges are the only ones that meet") is not quite true.
  2. formats/maven/mod.rs:468, :598: once MAX_SCOPE_WORK is used up, later declarations lose their PastWorkBound reading, so vex/discover/maven.rs profile_override stops reporting profile overrides for them. The maven_index path still fails closed. This needs a pom large or adversarial enough to use up the budget.
  3. gc.rs prune scope: a global prune now keeps the project's records, but a project scan --prune/--sync still treats records written by scan -g as uninstalled and sweeps them. After that, rollback -g can't find the patch. This is not new in this PR.
  4. apply.rs:963: when the undo fails twice, the original is left at .socket-orig-* and the error message points the user there. is_stale_stage then sweeps that path 10 minutes later. Only .socket-kept-* copies are safe from the sweep.

Checked with no problems found: every spawn site removes the Socket credential variables from the child's environment. Journal entries can't reach .git (case folding, 8.3 names, stream suffixes, symlinks). Grant origins are compared on scheme, host and port. Telemetry checks DO_NOT_TRACK before anything else, and its flush is capped at 300 ms. Bun, vlt, uv and pnpm byte-span edits stay in bounds, and Ruby heredocs, %w, CRLF and modifiers are read correctly.

Removed Ready for review so the burn-down re-labels this once CI is green on the new head.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@mikolalysenko

Mikola Lysenko (mikolalysenko) commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Final review brief: #2052

What it does: merges the 24 fix branches from the full 5.1.0 review. The rule behind them is that patching must never break a user's build and a skipped patch is always reported. Main areas: all-or-nothing apply and rollback, stricter vex evidence, a new Bundler hosted-mode Gemfile edit and gem refresh, a bare scan/rollback that covers every project below --cwd in a monorepo, and hardening against crafted lockfiles, POMs and journals.

Risk: high, because of its size (298 files, +56k/-8k) and because it changes behaviour on purpose. The PR description lists 9 behaviour changes under "Behaviour changes to confirm in review", with exit codes and refusals. Those 9 are where to spend your two minutes. The code itself has been reviewed by 20 auditors, a pre-PR review and this final review.

Look here:

  • commands/mod.rs:96 bare_scan_looks_below and rollback.rs:1520-1528: which projects a bare scan or rollback touches in a monorepo (behaviour change 7).
  • formats/gem/declarations.rs:``` the new hosted gem edit (behaviour change 4).
  • vex/discover/maven.rs:634 inherited: Maven <dependencyManagement> pins under an unread parent are no longer attested (behaviour change 3, the one item the description offers to relax).
  • telemetry.rs:180: the DO_NOT_TRACK handling.

Verified:

  • I reviewed the PR in five parts with parallel readers last run. That review found two defects, which I fixed in f16ca61 (see below). It also found four non-blocking follow-ups, listed in my previous comment. Three of them are already on main or need adversarial input.
  • CI on f16ca61: every check that ran passed, ci-ok and clippy included. The macOS, Windows, Docker and old-toolchain e2e jobs are skipped for PRs, so they will first run in the merge queue.
  • The branch is up to date with main and has no conflicts. CHANGELOG.md is untouched (the notes are in chore(release): 5.1.0 notes for the full-review fixes (merge after #2052) #2053). There are no unresolved review threads.
  • Bugbot: did not review, because the PR is too large for it.

Changes I made: f16ca61. A trailing \ in a Gemfile no longer carries a gem call over blank or comment lines; before this, the hosted edit could put source: on the wrong gem. A bare rollback no longer goes below --cwd when a bare scan would stop with mode_ambiguous. Each fix comes with a test that fails without it.

Open questions: none blocking. Merge #2053 (the changelog) after this PR.

Auto-merge is armed (squash, 2026-11-11 by the merge steward), so approving sends this PR straight to the merge queue. Merge #2053 after it.


Generated by Claude Code

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Nov 11, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Ready for review (burn-down agent, re-applied after the merge steward's fixes)

  • Head: f16ca6192b6e7935d9dafecfa4c40cd3aa5b2f3e
  • CI: 30/30 checks passed on this head (19 skipped). No main-wide failures.
  • Mergeable: yes, no conflicts. CHANGELOG.md is not changed here; the notes are in chore(release): 5.1.0 notes for the full-review fixes (merge after #2052) #2053.
  • Bugbot: did not review. The PR is too large for it, so there are no Bugbot findings to address.
  • What to look at: the 9 "Behaviour changes to confirm in review" in the description, and the final-review brief above.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants