Skip to content

Never read or rewrite project files outside the git repository root - #2025

Open
Mikola Lysenko (mikolalysenko) wants to merge 9 commits into
mainfrom
fix/reads-bounded-to-repo-root
Open

Mikola Lysenko (mikolalysenko) wants to merge 9 commits into
mainfrom
fix/reads-bounded-to-repo-root

Conversation

@mikolalysenko

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

Copy link
Copy Markdown
Collaborator

Owner decision: in workspace (project) runs, socket-patch must not read project files outside the git repository root, or outside the project root when there is no repository. Global runs (--global / --global-prefix) stay unbounded. This PR applies that rule to VEX discovery, the lock inventory, hosted mode and vendored mode. It also fixes one concrete bug, N5: the vendored writers could rewrite or delete wiring that belongs to another project.

Six commits:

  1. N5: vendored writers, reverters and GC claim only this project's vendor paths.
  2. Bounds: one shared repository-root check, applied to the reads listed below.
  3. Upward lookups: inside a git checkout, the workspace-root and settings walks never read above the repository root. A file there that the tool may honor makes writers refuse and readers withhold. Without a checkout, the walks behave as today.
  4. Workspace runs only: following the owner's clarification ("This behavior should be only for workspaces. We probably want to have global mode allow full system scans."), the bound is an explicit mode. A global run (--global / -g / --global-prefix) reads anywhere on the system.
  5. The npm install-tree lookups get the mode: NpmCrawler carries its run's scope, and the agent-mode apply/rollback engines take it, so the relocated-store probe, the peer fan-out and the shared-store guard are unbounded in a global run.
  6. Review findings: the /code-review high findings, each confirmed or refuted below.

4. The bound is a mode: workspace runs only

  • One explicit choice per run. utils::repo_root::ReadScope { Workspace, Global } is set from the CLI flags in exactly two places:

    • GlobalArgs::read_scope(), which is --global / --global-prefix;
    • CrawlerOptions::read_scope(), built from those same args.

    It is never inferred from the filesystem. ReadBoundary is now Workspace { root } or Unbounded. Every bound API takes the scope explicitly: ReadBoundary::of(scope, …), upward_bound(scope, …), upward_readable(scope, …), bounded_project_path(scope, …). Global makes all of them unbounded, with no marker checks.

  • Threaded where a global run can reach:

    • the cargo crawler's source-replacement lookup (crates_io_source(options.read_scope(), …) → config_layer_paths(scope, …));
    • agent-mode cargo build-cache invalidation (invalidate_project(common.read_scope(), …) from apply and rollback);
    • the npm crawler's relocated yarn store in crawl_all (the crawler's own scope, see commit 5);
    • apply's PnP gate (detect_npm_pkg_manager(args.common.read_scope(), …));
    • YarnEnv (a scope field, through for_project(scope, …) / current(scope));
    • go_workspace::resolve_in(scope, …);
    • gradle_cache::build_roots(scope, …).
  • Hard-wired to Workspace, audited as unreachable from a global run:

    • hosted mode and vendored mode (both refuse --global: global_mode_conflict);
    • VEX discovery and verify (skipped under --global in vex);
    • the lock inventory and ProjectView (project reads);
    • the hosted upstream restore;
    • the local-only crawler branches: the Go go.sum scope and the Gradle m2 gate both run only when the crawl is not global, and the cargo directory_source.
  • The npm install-tree lookups take the mode too (commit 5). NpmCrawler stores its run's scope: NpmCrawler::for_options(&CrawlerOptions), with_scope, or new() for a workspace run.

    • The CLI's npm crawls and resolvers (ecosystem_dispatch, scan) build the crawler from their options. find_by_purls and the local node_modules discovery pass that scope to the relocated yarn-store probe.
    • The agent-mode engines take the scope as their first argument: apply_package_patch, rollback_package_patch, rollback_store_copies_holding_patch and gem_ext::apply_gem_package_patch, from args.common.read_scope() / common.read_scope().
    • Those engines thread it into the store fan-out (store_copies::fan_out → find_store_peer_variant_copies(scope, …)) and the shared-store write guard (shared_store_of(scope, …) / shared_store_of_patch_dirs(scope, …) → active_yarn_pnpm_store(scope, …)).
    • with_store_peer_variant_copies(scope, …) takes the run's scope too. Its callers are vex and also apply --check (verify_installed_tree, through find_manifest_package_copies_reusing), which can run with --global / --global-prefix. So check and apply see the same copies.
    • Hard-wired to Workspace: the hosted Go redirect and the socket.yml nested-root policy.

Review findings (commit 6)

Each finding was checked first. Each behavior bug got a test that failed before the fix and passes after it.

  1. Confirmed: escape through .. after a symlink. canonical_or_lexical folded .. lexically before canonicalizing. With repo/link -> /outside/dir, the path repo/link/../secret.txt passed contains() while the OS opens /outside/secret.txt. The function now resolves physically: the whole path through canonicalize when it exists, else component by component (each existing level canonicalized before the next .. applies).
    • Test: utils::repo_root::tests::dot_dot_after_a_link_resolves_physically.
  2. Confirmed: in-tree names through an outward symlink. bounded_project_path checked only climbing or absolute names, so -r shared/base.txt through shared -> ../../other read outside. Every name is now checked on its REAL path. The fast path is one canonicalize of the path plus one of the project root; the repository lookup runs only when the path leaves the project. The project view (Disk and Snapshot) and the staged restore reads all go through it.
    • Test: utils::repo_root::tests::in_tree_names_through_an_outward_link_are_refused, covering bounded_project_path, ProjectView::Disk and ProjectView::Snapshot.
    • One fixture changed: pypi_requirements::tests::symlinked_requirements_refuses_wire_and_revert_without_writing linked to a file outside its project with no checkout. That read is now refused before the symlink refusal it tests can fire, so the link target moved inside the project. vendor_failure_paths::vendor_refuses_a_symlinked_lock_instead_of_replacing_it (CLI) got the same fixture change for the same reason.
  3. Confirmed: agent-mode cargo ignored a config above the repository. A workspace apply stopped the cargo config walk at the repository root, so a .cargo/config.toml above it that replaces crates.io was ignored while apply patched the registry cache.
    • apply and apply --check now refuse a run with Cargo patches in a Cargo project with cargo_config_outside_root (documented in CLI_CONTRACT.md). The message names the file and points at --global for patching the registry cache anyway.
    • invalidate_project warns cargo_build_cache_stale, naming that file, because a build.target-dir / build.build-dir set there was not read.
    • Tests: commands::apply::tests::cargo_patches_refuse_under_a_cargo_config_above_the_repository (CLI) and utils::cargo_build_cache::tests::a_config_above_the_repository_is_named_when_its_target_dir_is_unread.
  4. Confirmed: apply --check and apply could see different copies. with_store_peer_variant_copies hard-coded Workspace, but apply --check reaches it with --global / --global-prefix. It now takes the scope. The global caller passes common.read_scope() (ecosystem_dispatch), and vex_consumed passes its options' scope. The body's earlier "vex's every-copy check" wording is fixed above.
    • Test: added to crawlers::npm_crawler::tests::global_scope_reads_a_yarn_store_configured_above_the_repository. Global expansion includes the store twin; workspace expansion does not.
  5. Confirmed: the go.work and NuGet lookups were bounded without a checkout. The NuGet parent-config walk, the go.work lookup (resolve_scoped, use members, replace dirs, module_roots) and go_native_vendor used ReadBoundary::of, which falls back to the project directory when there is no checkout. They now use ReadBoundary::upward(scope, start): the checkout in a workspace run, Unbounded without one or in a global run.
    • I removed the .git dirs the go_consumer_sync, go_mod_edit, go_workspace, nuget_config, nuget_feed and upstream-npm fixtures had only to work around this.
    • go_workspace::tests::go_work_outside_the_repository_is_never_read now asserts the as-before answer without a checkout.
    • vex::discover::golang::tests::gowork_naming_another_workspace_attests_nothing has no checkout, so an out-of-project GOWORK is again "another workspace". The checkout case is covered by the go_workspace test.
  6. Confirmed: no home rule for an ancestor pnpm-workspace.yaml. governing_workspace_file failed closed on any pnpm-workspace.yaml above the repository, even one in the home directory. It now applies the same never-at-or-above-home rule as marker_above (shared repo_root::at_or_above_home).
    • Test: utils::pnpm_workspace::tests::a_workspace_file_above_the_repository_honours_the_home_rule, with home injected.
  7. Confirmed: Windows drive paths in pnpm <= 8 locks. parse_vendor_path_or_machine_absolute relied on relpath::is_anchored, which recognizes C:/ only on Windows. A drive-absolute specifier (C:\… / file:D:/…) is now machine-absolute on every OS, so a lock written on Windows reverts on Linux CI. A drive path with .. still is not.
    • Test: added to vendor::path::tests::ownership_parse_is_root_anchored; it failed on macOS before the fix.
  8. Confirmed: NpmCrawler had two scope sources. It read both self.scope and options.read_scope(); it now has one. get_node_modules_paths, crawl_all, crawl_all_with_roots, find_by_purls and local node_modules discovery all use the crawler's own scope, set from options at construction (for_options).
    • A debug_assert! catches a workspace crawler handed global options. It found 15 crawler_npm_e2e tests, one global_probe_spawn_e2e test, one npm_crawler unit test and the test oracle building NpmCrawler::new() with global options; they now build a global crawler.
    • Production callers already used for_options.
  9. Done: perf.
    • uv_config_above_repo(root, ancestor) takes the caller's find_ancestor_uv_config answer instead of looking it up again.
    • go_native_vendor::Target carries a bound computed once per check (not per replace entry and file).
    • vex::verify computes the node_resolve bound once per package (not per dependency).

Other open PRs that apply the repository-root rule (not changed here)

1. N5: vendored writers claimed a sibling checkout's vendor paths

vendor::parse_vendor_path matched the .socket/vendor/ anchor anywhere in a string. Discovery's vendor_ref already required the path to start with that anchor. So a lock entry such as

"node_modules/left-pad": { "resolved": "file:../.socket/vendor/npm/<uuid>/left-pad-1.3.0.tgz" }

points at a sibling checkout's vendor store, but every vendored writer, reverter and GC treated it as ours. If the same patch uuid was vendored in both checkouts, vendor --revert wrote this project's recorded pre-vendor original over the other project's wiring.

Fix (vendor/path.rs):

  • parse_vendor_path and parse_vendor_reference (the ownership parse) now accept only a root-anchored path:

    • only . and .. segments may come before the anchor, and they must land exactly on the project root;
    • parse_vendor_path_from(base, s) handles a file below the root, for example ../../.socket/… from packages/a.
  • One function, vendor_rest_from, now does this anchoring for both the writers and discovery's vendor_ref, so the two cannot drift apart again.

  • parse_vendor_locator: yarn berry resolution: "left-pad@file:./.socket/…" has its name@ cut before the root-anchored parse.

  • parse_vendor_path_or_machine_absolute covers pnpm <= 8 only. pnpm itself writes file: override specifiers as machine-absolute paths, so a committed lock holds another checkout's absolute root. Such a path is still claimed when it names the entry's own uuid and leaf, but never when it contains ... DepRef gains a parse field so the shared pnpm revert uses this rule only for legacy locks.

  • recognize_vendor_path_anywhere keeps the old match-anywhere behavior, for recognition only. Its callers never rewrite anything:

    • the lock inventory's "is this a vendored spec" checks;
    • composer and pnpm registry listings;
    • discovery's hosted-wiring probe;
    • the binary-lock half of scan_vendor_references, which repair, vendor --check, rollback's ledger-missing guard and the orphan sweep use.

    A sibling reference there can only keep one of our own dirs from being swept, or flag it as still wired. It never leads to a rewrite.

Every parse_vendor_path caller was reviewed:

  • Writers, reverters and GC (npm, yarn classic/berry, pnpm v9/legacy, bun, composer, gem, every pypi flavor, verify, vendored_wiring_in_files): strict.
  • Recognition-only callers: recognize_vendor_path_anywhere.
  • The requirements planner: strict at base "". pip resolves vendor-line paths against the working directory, not the including file.

2. Bounds added

The shared check lives in utils/repo_root.rs:

  • ReadBoundary::of(project_root) returns the root of find_git_repo, else the project root.
  • contains(path) resolves the path physically, as the OS does (see review finding 1), so neither .. nor a symlink can escape.
  • bounded_project_path(scope, root, rel) returns NotFound (a refused read looks like a missing file). It checks every name on its real path (see review finding 2).
  • is_refused_read(&e) lets a writer tell "refused" apart from "missing" and refuse.
Read Before Now
go.work (go_workspace::resolve_with / load_with, the shared loader that #2011 makes discovery use too) nearest ancestor go.work up to /, or any absolute GOWORK Outside the root it is only stat'ed, never opened. The result is GoWork::Invalid with the reason, so vendored and hosted writers refuse through the existing workspace refusal and discovery withholds.
go.work use members outside the root go.mod / go.sum read read as missing, so the selection is Unknown
replace => ../dir go.mod in the module graph (read_dep_go_mod) and go_native_vendor::expected_text read missing
ProjectView::read_text / read_bytes (Disk + Snapshot), used by discovery, the lock inventory and the requirements walks: -r includes, yarn file:/portal:/link: copies, any ..//absolute rel read missing to readers. The vendored requirements planner (collect_requirements_files) and the lock flavors' pin_outside_root refuse with pypi_requirements_outside_root.
Hosted upstream restore staged reads (redirect/staged.rs::read_rel), including the absolute ancestor pnpm-workspace.yaml / .npmrc probes read the restore refuses with read <rel>: … is outside the repository root …
NuGet parent nuget.config (nuget_config::inherited_source_keys_traced) every ancestor to / Above the root it is only stat'ed. Inherited::outside names it; vendoring refuses, and hosted mode skips each NuGet dep with the new code redirect_nuget_inherited_config_outside_root, documented in CLI_CONTRACT.md

CLI_CONTRACT.md has a new section, "Reads bounded to the repository root", and a sentence in the Go paragraph.

Machine-level reads (not changed)

  • crates/socket-patch-core/src/utils/go_env.rs:26 (go_env), :148-177: the GOENV file ($GOENV, else the OS config dir's go/env), used for GOWORK / GOFLAGS / GOPROXY / GOPRIVATE.
  • crates/socket-patch-core/src/utils/go_env.rs:62 gomodcache: the Go module cache. go.mod files are read from it in vendor/go_workspace.rs (read_dep_go_mod), vendor/go_build_list.rs:313 and vendor/go_native_vendor.rs:55.
  • crates/socket-patch-core/src/vendor/nuget_config.rs:56 user_config_path (read at :145) and settings_chain: the user NuGet.Config (%APPDATA% / $DOTNET_CLI_HOME / ~/.nuget/NuGet/NuGet.Config).
  • crates/socket-patch-core/src/patch/redirect/npmrc.rs:564 outer_config_layers: npm builtin npmrc, user ~/.npmrc / npm_config_userconfig, global <prefix>/etc/npmrc.
  • crates/socket-patch-core/src/patch/redirect/yarnrc.rs:317 RcLayout::files: ~/.yarnrc, ~/.npmrc, <prefix>/etc/yarnrc|npmrc.
  • crates/socket-patch-core/src/crawlers/pkg_managers.rs:208,247: the home .yarnrc.yml.
  • crates/socket-patch-core/src/patch/redirect/upstream/npm.rs (~639-648 home .yarnrc.yml; ~2057-2075 / ~2466-2474 $XDG_CONFIG_HOME/.npmrc or ~/.npmrc, and .bunfig.toml).
  • crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:999 global_uv_settings: $XDG_CONFIG_HOME/uv/uv.toml, $XDG_CONFIG_DIRS, /etc/uv/uv.toml, %APPDATA%, %PROGRAMDATA%.
  • crates/socket-patch-core/src/vendor/cargo_config.rs:150-178 and crates/socket-patch-core/src/formats/cargo/source_replacement.rs:112-139: $CARGO_HOME/config*.
  • crates/socket-patch-core/src/crawlers/ruby_crawler.rs:51,173 (and ~2012-2035, ~2221-2240): Bundler app config ($BUNDLE_APP_CONFIG) and global config ($BUNDLE_CONFIG, $BUNDLE_USER_CONFIG, $BUNDLE_USER_HOME, ~/.bundle/config).
  • crates/socket-patch-core/src/crawlers/maven_crawler.rs:619 m2_repo_path_with: ~/.m2/settings.xml, $MAVEN_HOME/conf/settings.xml. Also jvm_cache.rs local repository and Gradle cache roots.
  • crates/socket-patch-core/src/gradle/home.rs, crawlers/gradle_cache.rs: the Gradle user home init.d and wrapper init scripts.
  • crates/socket-patch-core/src/utils/repo_root.rs:33 GitRepo::config_path: git's own config, which a gitdir: / commondir may place outside the worktree.

3. Upward lookups bounded (third commit)

The upward walks, where a package manager searches the project's ancestors for a workspace root or a settings file, now use the same ReadBoundary, through repo_root::upward_bound(start):

  • With a git checkout: a walk never reads above the repository root. Where stopping there could change what a writer does, ReadBoundary::marker_above(names) checks whether a file the walk would have read exists above the root. This is a stat only, never an open, and it stops before the home directory, like git's own home rule. If such a file exists, writers refuse with the existing workspace codes and readers withhold, the same pattern as the ancestor go.work.
  • Without a checkout: upward_bound is None and every walk behaves exactly as today. Bounding at the project there would silently treat workspace members as standalone and turn today's refusals into wrong writes.
Walk Inside a checkout, when a file the walk reads exists above the root
governing_root: pnpm_lock_elsewhere, package_json_workspace_root, vlt_workspace_root (lockless npm/yarn/Bun/pnpm/vlt member) npm_lock_elsewhere names the package.json / pnpm-workspace.yaml / vlt.json above. Hosted: redirect_workspace_lockfile_elsewhere. Vendored: vendor_lockfile_missing.
member_stray_lock (a member's own npm/Bun/vlt lock) Treated as stray under the workspace above: hosted and vendored refuse, discovery reads the locks as absent.
pnpm_workspace::governing_workspace_file The nearest pnpm-workspace.yaml above fails closed as governing, without being read. Hosted: redirect_pnpm_settings_elsewhere. Vendored: vendor_pnpm_settings_elsewhere. git_branch_locks treats an unread gitBranchLockfile as on. The upstream pnpm restore refuses each pin and reads nothing (read_rel bounds absolute upward reads the same way).
uv_workspace member walk governing_uv_refusal. Hosted: redirect_workspace_lockfile_elsewhere. Vendored: pypi_uv_workspace_unsupported.
python_script::find_ancestor_uv_config uv_config_above_repo. Vendored uv / pylock / script: pypi_uv_ancestor_settings. Hosted: a new synthetic key UV_ANCESTOR_OUTSIDE_KEY makes the uv project/script (redirect_uv_*_unsupported) and uv pip pylock (redirect_uv_lock_unsupported) wiring refuse.
cargo::workspace_root_refusal cargo_manifest_not_workspace_root, naming the Cargo.toml above.
cargo_config::read_config_chain_with, source_replacement::config_layer_paths Configs above are not read. cargo_config::config_above_repo makes vendored refuse (user_authored_patch_entry). Hosted refuses the run with the new code redirect_cargo_config_outside_root (in CLI_CONTRACT.md). The scan crawler reads the in-repo layers plus $CARGO_HOME.
cargo::path_crate_version ([patch] path = "../x" outside the root) Version unknown, so user_authored_patch_entry.
cargo_build_cache::workspace_root (agent mode) Stays inside the checkout. A workspace above it is neither read nor has its target dir invalidated.
pkg_managers .yarnrc.yml walks nodeLinker reads as an unknown value, which fails closed as PnP and names the file. No pnpmStoreFolder store is admitted. injectEnvironmentFiles dotenv files outside the checkout (other than beside the home rc) inject nothing.
upstream::npm::BerryRegistrySettings::read The registry lookup refuses.
yarn classic RcLayout::files (ancestor .yarnrc / .npmrc) The outer offline mirror fails closed, so redirect_yarn_classic_offline_mirror names the file.
yarn_classic_workspace_members A directory link out of the checkout is never descended into. A member at such a link is listed (stat only), and its rc files are never read: the member mirror fails closed, naming it.
maven_repo::not_build_root A pom.xml, Gradle settings or build.sbt above (for example the superproject of a submodule) refuses not_build_root.
gradle_cache::build_roots, vex::verify::node_resolve Readers. Settings or a node_modules copy above read as absent, so the dependency is unresolved and not verified.

Already bounded inside a checkout, so unchanged: hatch_workspace::roots (search_dirs stops at the checkout) and vlt_project_root (stat only, stops at .git).

Still not bounded, or changed in other ways

  • Conservative by design (commit 3): the check for a file above the root cannot read it, so any such file counts, even one that would not govern this project when read. These are a package.json, pyproject.toml, Cargo.toml, .cargo/config*, .yarnrc(.yml), .npmrc, pom.xml or Gradle settings in a directory between the repository root and the home directory. A .yarnrc.yml there makes the yarn berry linker fail closed as PnP. Home and above are never checked, so a stray ~/package.json changes nothing. If this refuses too often, the owner could narrow the markers, for example to package.json files that are not the repository's own.
  • vendor/go_consumer_sync.rs modules_txt_location: when the ancestor go.work is out of bounds, resolve now says Invalid and this falls back to the project's own vendor/modules.txt. The audit there may then miss staleness in the out-of-root workspace, but the write path refuses first.
  • The scan_vendor_references text scan (repair / vendor --check / rollback guard / orphan sweep) still recognizes a ../.socket/vendor/… mention as a reference. It never rewrites it. The only effect is that one of our own dirs with the same uuid is kept rather than swept, or reported as still wired.
  • Discovery's out-of-repository -r includes (Vendored pip: a pin in a -r include outside the project root is ignored when a pylock.toml exists, so vendor --check passes and vex attests not_affected while pip install -r requirements.txt installs the unpatched release #1851) now read as missing, following "readers treat a refused read as missing". Their registry pins no longer contest a lock's wiring. Includes outside the project but inside the repo are still read.

Tests

Each new test fails with the bound disabled (ReadBoundary::contains forced to true) or with the old anywhere-anchored parse, and passes with this change:

  • vendor::npm_lock::tests::revert_never_claims_a_sibling_checkouts_vendor_path (N5: the entry is left byte-for-byte, vendor_lock_entry_drifted, artifact kept)
  • vendor::path::tests::ownership_parse_is_root_anchored
  • utils::repo_root::tests::read_boundary_is_the_checkout_else_the_project, read_boundary_sees_through_links
  • vendor::go_workspace::tests::go_work_outside_the_repository_is_never_read
  • vex::discover::golang::tests::gowork_naming_another_workspace_attests_nothing (expectation updated: an out-of-repo GOWORK now says "outside the repository root")
  • vendor::pypi_requirements::tests::includes_outside_the_repository_are_never_read
  • vendor::nuget_config::tests::a_parent_config_above_the_repository_is_never_read, vendor::nuget_feed::tests::vendor_refuses_under_a_parent_config_above_the_repository
  • patch::redirect::tests::nuget_inherited_config_outside_the_repository_skips_the_dep
  • patch::redirect::staged::tests::reads_outside_the_repository_refuse (also checks that an absolute upward read is unchanged without a checkout)

Upward lookups (commit 3). Each test fails with upward_bound forced to None and, where it applies, also checks that behavior without a checkout is unchanged:

  • utils::repo_root::upward_tests:
    • marker_above_is_stat_only_and_starts_above_the_checkout
    • npm_family_workspace_root_above_the_repository
    • member_stray_lock_above_the_repository
    • pnpm_workspace_file_above_the_repository_fails_closed
    • uv_workspace_root_above_the_repository
    • uv_settings_above_the_repository
    • cargo_workspace_and_configs_above_the_repository
    • gradle_settings_above_the_repository
    • yarnrc_yml_above_the_repository (linker, store and injectEnvironmentFiles)
    • yarn_classic_rc_above_the_repository
  • vendor::cargo::tests::patch_path_outside_the_repository_is_never_read
  • utils::cargo_build_cache::tests::workspace_root_stays_in_the_repository
  • vendor::maven_repo::tests::a_build_above_the_repository_refuses_unread
  • patch::redirect::yarnrc::tests::a_member_linked_from_outside_the_repository_fails_closed
  • vex::verify::tests::node_resolve_stays_in_the_repository

Workspace versus global (commit 4):

  • utils::repo_root::tests::read_boundary_is_the_checkout_else_the_project now also checks that ReadScope::Global is Unbounded: contains is true everywhere, there is no marker_above, and bounded_project_path / upward_bound / upward_readable are unbounded.
  • utils::repo_root::upward_tests::global_scope_reads_above_the_repository: in a checkout with files above its root, the Global scope reads them (go.work, .cargo/config.toml, Gradle settings, .yarnrc.yml linker and store), while Workspace stays bounded.
  • crawlers::npm_crawler::tests::global_scope_reads_a_yarn_store_configured_above_the_repository (commit 5): a yarn project inside a git checkout, with nodeLinker: pnpm / pnpmStoreFolder in a .yarnrc.yml above the checkout.
    • Global scope: the relocated-store probe, find_by_purls (store-only transitive copy and its twin), find_store_peer_variant_copies, the --global-prefix crawl and the shared-store guard all see the store.
    • Workspace scope: none of them does. The guard refuses the link as a source outside every node_modules tree.
  • utils::repo_root::upward_tests::global_crawl_reads_a_package_outside_the_repository: a --global-prefix npm crawl finds a package outside any repository while the working directory is inside one. CrawlerOptions::read_scope() is Global for it and Workspace for a local crawl.

Not covered by a dedicated test: the Berry registry refusal, git_branch_locks, the upstream pnpm-settings refusal, and the hosted uv / cargo synthetic-key plumbing. Each of these consumes a bound that the tests above exercise.

Fixtures that model "beside the project, inside the checkout" for a non-upward reference (the pypi out-of-root include tests, discover's Project::nested, one hosted engine test) create a .git at their tempdir root. The go, NuGet and upstream-npm fixtures that only needed one to work around review finding 5 no longer do.

Local runs (macOS):

Run Result
cargo test -p socket-patch-core --lib -- vendor:: vex::discover:: patch::redirect:: hosted:: utils::repo_root 4406 passed
vex::discover::testing::golden (incl. committed_fixture_corpus_matches_golden) 4 passed, no golden changes
cargo test -p socket-patch-core --lib 7166 passed
core --test crawler_npm_e2e / crawlers_empty_paths_e2e / global_probe_spawn_e2e 128 / 13 / 10 passed
--test global_scope_project_state 47 passed
--test in_process_rollback_all_ecosystems / in_process_agent_reapply 28 / 27 passed
socket-patch-cli --lib 869 passed
--test vendor 159 passed
--test in_process_rollback_vendored 23 passed
--test in_process_remove_repair_lifecycle 41 passed
--test in_process_vendor_bun_takeover 45 passed
--test in_process_redirect 152 passed
--test in_process_rollback_hosted 41 passed
--test hosted_memory_engine 49 passed
--test in_process_scan 27 passed
cargo clippy --locked --workspace --all-features -- -D warnings clean
cargo fmt --all -- --check clean

Note

Medium Risk
Broad behavioral change to which paths are read and which runs refuse; could affect monorepos, out-of-tree tool config, and symlink-heavy layouts; global mode is explicitly exempt—review edge cases there.

Overview
Workspace vs global: Project runs now fix a ReadScope (Workspace = never read project files outside the git checkout—or project dir without .git; Global = unbounded). GlobalArgs::read_scope() and CrawlerOptions::read_scope() drive this everywhere the diff threads it.

npm / Yarn: NpmCrawler is constructed with that scope (for_options / with_scope). Relocated Yarn pnpm stores, find_by_purls, peer fan-out (find_store_peer_variant_copies, with_store_peer_variant_copies), and local node_modules discovery respect it—so global runs can still see config above the tree; workspace runs do not. detect_npm_pkg_manager, YarnEnv, yarn_node_linker, active_yarn_pnpm_store, and live_pnp_marker take ReadScope and stop upward rc/env reads at the repository boundary (with fail-closed linker/store behavior when an unread .yarnrc.yml would govern).

Cargo / Go / Gradle: Cargo source replacement and config layering use scoped walks (config_layer_paths, crates_io_source); agent apply/apply --check refuse Cargo patches with cargo_config_outside_root when a .cargo/config* above the repo exists (stat-only detection), with JSON/human messaging and build-cache invalidation passing scope. Go workspace detection uses go_workspace::resolve_in(Workspace, …). Gradle build_roots and maven-local analysis stop at the repo root for ancestor settings.

Agent apply/rollback / vex: Patch application, rollback, gem native rebuild, store copy rollback, cargo cache invalidation, and VEX copy expansion pass read_scope through to the core helpers so global --global / --global-prefix matches unbounded npm behavior.

Hosted / governing root: Candidate collection treats refused reads (paths leaving the repo via symlinks) like linked reads; NuGet inherited config and uv ancestor config record synthetic “outside root” keys. New refusal redirect_cargo_config_outside_root. Governing-root checks use upward_bound / upward_readable: pnpm workspace/trust, lock-elsewhere, stray-member, and uv workspace paths no longer read or assume governance from above the checkout without refusing; lock_marker_above / stray_lock_marker_above cover lockless projects with markers only above the repo.

Vendor recognition: Repair/scan binary lock scanning uses recognize_vendor_path_anywhere (match anywhere for wiring detection only); composer/pnpm listing skips use the same for “already vendored” detection. Ownership parsing for writers stays root-anchored (per PR narrative).

Docs & tests: CLI_CONTRACT.md documents bounded reads and Go go.work outside the repo. New/updated tests cover cargo refusal, global vs workspace Yarn store visibility, disk symlink refusal, and fixtures keep includes/lock targets inside the repo where reads are now refused.

Reviewed by Cursor Bugbot for commit 230cba3. Configure here.


Generated by Claude Code

`parse_vendor_path` found the `.socket/vendor/` anchor anywhere in a
string, so a lock entry naming a sibling checkout's store
(`file:../.socket/vendor/npm/<uuid>/x.tgz`) read as ours to every
vendored writer, reverter and GC, while discovery's `vendor_ref` already
required a root-anchored path. With the same patch vendored in both
checkouts, `vendor --revert` restored our recorded original over the
other project's wiring.

The ownership parse is now root-anchored and shared with discovery
(`vendor_rest_from`): only `.`/`..` may precede the anchor and they must
land exactly on the project root (`parse_vendor_path_from` for a file
below the root). Yarn berry locators cut their `name@` first
(`parse_vendor_locator`); pnpm <= 8 keeps its machine-absolute specifier
(`parse_vendor_path_or_machine_absolute`), which pnpm itself writes.
Readers that only ask "is this a vendored spec" (the lock inventory,
composer/pnpm registry listings, the hosted-wiring probe, the reference
scan behind repair and the orphan sweep) use
`recognize_vendor_path_anywhere`, which nothing rewrites by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One shared bound (`utils::repo_root::ReadBoundary`): the git checkout
enclosing the project, else the project itself; `..` and symlinked
levels are resolved before the check. `bounded_project_path` refuses a
climbing or absolute project-relative path as `NotFound` (a reader sees
a missing file), and `is_refused_read` lets a writer refuse instead.

- go.work: an ancestor's or an absolute `GOWORK` outside the root is
  only stat'ed; the workspace is Invalid, so writers refuse and
  discovery withholds. `use` members and `replace` directories outside
  the root read as missing (the selection stays Unknown).
- ProjectView reads (discovery, lock inventory, requirements walks,
  yarn file:/portal:/link: copies) and the hosted restore's staged
  reads are bounded; the requirements planner and the lock flavors'
  out-of-root pin check refuse `pypi_requirements_outside_root`, the
  restore refuses with the reason.
- NuGet parent configs above the root are not read; vendoring refuses
  and hosted mode skips the dep with
  `redirect_nuget_inherited_config_outside_root` (CLI_CONTRACT.md).

Fixtures that model files beside the project inside a checkout now
create the `.git` they assumed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inside a git checkout, the walks that search a project's ancestors for
a workspace root or a settings file never read above the repository
root (`repo_root::upward_bound`). When a file the walk would read sits
above it (stat only, never in the home directory or above), the tool may
honor it, so writers refuse with the existing workspace codes and
readers withhold: npm/yarn/Bun/pnpm/vlt governing roots and stray member
locks, the governing pnpm-workspace.yaml (and its gitBranchLockfile and
restore settings), uv workspace roots and uv settings, the cargo
workspace root, `.cargo/config` chain and `[patch]` paths, `.yarnrc.yml`
linker/store/env files, Berry registry settings, yarn classic rc files
and linked members, and Maven/Gradle/sbt build roots. Hosted cargo
refuses with the new `redirect_cargo_config_outside_root`. Without a
checkout every lookup behaves as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 5, 2026
This fixes the confirmed /code-review high findings on #2011. Each fix
has a test that fails on the previous commit.

- A Go workspace mark now applies to one file only. The new
  UnattestedKind::file_scoped is true only for GoWorkspaceOverride.
  vex_sources drops the bypassed ref from the candidate's basis and
  vetoes the patch only when no live ref of it remains. wiring_conflicts
  ignores bypassed refs. So a root go.work replace that go never reads no
  longer withholds the root go.mod's mirror of it. A leftover member pin
  that Socket's own go.work replace overrides no longer turns into a
  wiring conflict that also withholds the go.work patch, which is what
  vex.rs's WorkHosted exemption intends.
- A replace of a module that is itself a `use` member is diagnosed as
  inert again, both on disk and in memory, hosted and vendored. This
  check now runs before the workspace bypass.
- SumPins now also reads the sum files the loader already read: the
  governing go.work.sum and each out-of-root member's go.sum. They are
  used only when they lie inside the git repository.
- In memory, workspace conflicts are evaluated in
  go_workspace::PathSpace::Memory. Paths are compared lexically against
  the view's files and never against the filesystem or the working
  directory. conflict/work_conflict delegate to the new *_in variants,
  so disk behavior is unchanged.
- The snapshot probe list is now built by one shared helper
  (workspace_probes), spelled the way resolve_with spells it.
- A root go.work that go doesn't read is no longer judged unparseable,
  and its wiring stays an unattested ref.
- The go_workspace_override omission phrase now covers every case.
- read_loaded_workspace uses members_within. The view re-reads only the
  in-root members' go.mod, which recognition and parse diagnostics need.
  The duplicate go.sum and go.work.sum reads are gone.
- Tests that use a nested project now create a .git, so they keep
  working once reads are bounded to the repository root (#2025).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Owner clarification: the bound applies to workspace runs only; a global
run (`--global` / `-g`, `--global-prefix`) must read anywhere on the
system. `ReadScope { Workspace, Global }` is chosen once from the CLI
flags (`GlobalArgs::read_scope`, `CrawlerOptions::read_scope`) and
passed to every bounded read; `ReadBoundary` is now
`Workspace { root } | Unbounded`, and `ReadBoundary::of`,
`upward_bound`, `upward_readable` and `bounded_project_path` take the
scope explicitly instead of inferring it.

Global-reachable paths take the run's scope: the cargo crawler's source
replacement and `config_layer_paths`, agent-mode cargo build-cache
invalidation from apply/rollback, the npm crawler's relocated yarn
store, apply's PnP gate, `YarnEnv`, `go_workspace::resolve_in` and
Gradle `build_roots`. Hosted, vendored, VEX discovery/verify, the lock
inventory and the upstream restore never run globally and pass
`Workspace`. CLI_CONTRACT.md documents the distinction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) added a commit that referenced this pull request Nov 5, 2026
Owner clarification: the repository-root bound is for workspaces; a
global run should be able to read anywhere on the system. The shared
user-patch walk now takes a ReadScope { Workspace, Global }, chosen once
from the CLI mode (GlobalArgs::read_scope, from --global / -g /
--global-prefix), and only a Workspace walk bounds its reads: a
workspace member or --patch-dir beyond the git repository root protects
every npm package instead of being read. A Global walk reads both
wherever they are and protects exactly what they patch. The ancestor
walk itself (stop at .git, never reach the home directory) is the same
in both scopes.

UserPatchIndex::discover stays the workspace walk;
UserPatchIndex::discover_scoped takes the scope, which the CLI's
inspect() now threads through. A global run still inspects no project
(a local checkout does not govern the global tree, #1646), so the
Global arm is reached through the core API today. vex discovery and the
in-memory hosted engine only run on workspaces and keep the bound.

ReadScope has the shape of #2025's utils::repo_root::ReadScope (same
variants and of_global), so it is swapped for that type when the two
branches meet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last lookups without the run's mode now get it, so a global run is
unbounded and a workspace run stays inside the repository root:

- `NpmCrawler` stores its scope (`for_options`, `with_scope`; `new()` is
  a workspace crawler). `find_by_purls` and local `node_modules`
  discovery pass it to the relocated yarn-store probe; the CLI's npm
  crawls and resolvers build the crawler from their `CrawlerOptions`.
- The agent-mode engines (`apply_package_patch`,
  `rollback_package_patch`, `rollback_store_copies_holding_patch`,
  `gem_ext::apply_gem_package_patch`) take the scope from the CLI and
  thread it into the store fan-out (`find_store_peer_variant_copies`)
  and the shared-store write guard (`shared_store_of*`).

Test: a yarn project in a git checkout configured by a `.yarnrc.yml`
above it; the global scope sees the relocated store everywhere, the
workspace scope nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Resolve paths physically (canonicalize, else component by component),
  so `link/../x` climbs from the link's target as the OS does.
- Check every project-file read on its real path, not only climbing or
  absolute names: an in-tree name through an outward symlink is refused.
- Agent-mode cargo: `apply` / `apply --check` refuse Cargo patches when a
  `.cargo/config(.toml)` above the repository may replace crates.io
  (`cargo_config_outside_root`); build-cache invalidation names that
  file, whose target dir was not read.
- `with_store_peer_variant_copies` takes the run's scope, so a global
  `apply --check` sees the copies a global apply writes.
- go.work / go native vendor / NuGet parent configs use upward-lookup
  semantics: without a checkout, as before; drop the test `.git` dirs
  that only worked around it.
- An ancestor `pnpm-workspace.yaml` at or above home never governs.
- Drive-absolute pnpm <= 8 specifiers are machine-absolute on every OS.
- `NpmCrawler` keeps one scope source (its own, from its options).
- Look up the ancestor uv config once; compute the go native vendor and
  vex node-resolution bounds once per check / package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Nov 6, 2026
Clears the conflicts left when the base took the 2026-11-05 owner
decisions and the loc-budget counter fix.

- lock-model-divergences.md: keep this PR's "Resolved in PR 2a" detail
  for N4, N5, N12 and N14, tagged with #2009, plus the base's PR links
  (#2025, #2029). N11 takes the base's owner decision (shadowed), which
  replaces "pending owner decision".
- loc-budget.json: the base re-measured lock-readers with the fixed
  counter (30259). This PR grows the group, so the max is raised by hand
  to its measured count under the new counter (30482).

Co-Authored-By: Claude <noreply@anthropic.com>
Mikola Lysenko (mikolalysenko) pushed a commit that referenced this pull request Nov 6, 2026
Clears the conflicts left when the base merged lock-model/1-origin.

- lock-model-divergences.md: keep this PR's PR 2b resolutions (N5, N11,
  N15, N16) and tag them #2029, take the base's #2009 tags for the PR 2a
  rows, its #2025 note on N5, and its P4 note on out-of-root includes.
- loc-budget.json: set both groups to their measured counts under the
  base's fixed counter (this PR shrinks both).

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review November 6, 2026 12:12
The repository-root bound made a hosted candidate lock symlinked out of
the root (uv.lock, package-lock.json, vlt-lock.json -> ../shared/) read
as missing on disk, so `scan --mode hosted` exited 0 having silently
skipped a lock the package manager still honors. A disk read the bound
refuses on a symlinked candidate now lands in `symlinked_reads`, and the
guard checks those on every view, so the run refuses with
redirect_symlinked_file_unsupported as before.

Three e2e fixtures relied on reading a file outside any repository:
- yarn-classic vendor symlinked lock: keep the link target inside the
  project, as vendor_failure_paths already does;
- yarn-classic `file:../ext/lpdir` copy and the pip `-r ../shared/base.txt`
  include: make the temp dir the repository, so the sibling stays outside
  the project but inside the repository (the monorepo layout these tests
  model). Out-of-repository includes are covered by
  pypi_requirements::tests::includes_outside_the_repository_are_never_read.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Err(e) if crate::utils::repo_root::is_refused_read(&e) && view.is_symlink(rel) => {
self.symlinked_reads.push(rel.to_string());
None
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hosted guard misses directory-symlink escapes

Medium Severity

The new hosted arm records a refused candidate only when view.is_symlink(rel) is true, which lstats the final component. A rewrite target whose real path leaves the repository through an intermediate directory link still falls through as missing. Hosted can then skip that lock or member while the package manager still follows the link.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8250521. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 230cba3. Candidate names never climb, so any read the bound refuses means a link (the file or a directory on its path) leaves the repository root; every such read is now recorded and refused, and the directory-link case gets its own message under redirect_symlinked_file_unsupported. Test: hosted::engine::tests::disk_candidates_linked_out_of_the_repository_are_refused.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reopening: the fix in 230cba3 refused every bound-refused candidate read, which also refused install-state reads (node_modules/.modules.yaml when node_modules links out of the repository) and broke the vlt heal tests in coverage (1). Reverted in f28eaef. The directory-link escape you raised is real; the fix needs to refuse only rewrite targets (locks and manifests), not install-state reads. That is left for the next round on this PR.


Generated by Claude Code

Address Bugbot on 8250521: the hosted arm recorded a bound-refused
candidate only when the file itself was a link, so a candidate whose
real path left the repository through a directory link on its path
(`sub -> ../outside/sub`, then `sub/package-lock.json`) still read as
missing and hosted skipped a lock the package manager follows. Every
bound-refused candidate read is now recorded (candidate names never
climb, so only a link can take one out of the root), and the guard
names the directory-link case in its own message under the same code.

Co-Authored-By: Claude <noreply@anthropic.com>
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 230cba3. Configure here.

… link"

This reverts commit 230cba3. Refusing every bound-refused candidate read
also refuses install-state reads such as node_modules/.modules.yaml when
node_modules links out of the repository, which broke the vlt heal tests
(in_process_redirect vlt::scan_redirect_vlt_heal_undeterminable_*,
vlt::scan_redirect_vlt_heal_invalidation_failure_warns). The directory-
link case Bugbot raised needs the refusal limited to rewrite targets
(locks and manifests), not install state; left for a follow-up round.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants