Repository navigation
Never read or rewrite project files outside the git repository root - #2025
Mikola Lysenko (mikolalysenko) wants to merge 9 commits into
Conversation
`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>
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>
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>
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>
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>
|
bugbot run Generated by Claude Code |
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>
|
bugbot run Generated by Claude Code |
| Err(e) if crate::utils::repo_root::is_refused_read(&e) && view.is_symlink(rel) => { | ||
| self.symlinked_reads.push(rel.to_string()); | ||
| None | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 8250521. Configure here.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>
|
bugbot run Generated by Claude Code |
There was a problem hiding this comment.
✅ 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>


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:
--global/-g/--global-prefix) reads anywhere on the system.NpmCrawlercarries 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./code-review highfindings, 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.
ReadBoundaryis nowWorkspace { root }orUnbounded. Every bound API takes the scope explicitly:ReadBoundary::of(scope, …),upward_bound(scope, …),upward_readable(scope, …),bounded_project_path(scope, …).Globalmakes all of them unbounded, with no marker checks.Threaded where a global run can reach:
crates_io_source(options.read_scope(), …)→config_layer_paths(scope, …));invalidate_project(common.read_scope(), …)fromapplyandrollback);crawl_all(the crawler's own scope, see commit 5);apply's PnP gate (detect_npm_pkg_manager(args.common.read_scope(), …));YarnEnv(ascopefield, throughfor_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:--global:global_mode_conflict);--globalinvex);ProjectView(project reads);go.sumscope and the Gradle m2 gate both run only when the crawl is not global, and the cargodirectory_source.The npm install-tree lookups take the mode too (commit 5).
NpmCrawlerstores its run's scope:NpmCrawler::for_options(&CrawlerOptions),with_scope, ornew()for a workspace run.ecosystem_dispatch,scan) build the crawler from their options.find_by_purlsand the localnode_modulesdiscovery pass that scope to the relocated yarn-store probe.apply_package_patch,rollback_package_patch,rollback_store_copies_holding_patchandgem_ext::apply_gem_package_patch, fromargs.common.read_scope()/common.read_scope().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 arevexand alsoapply --check(verify_installed_tree, throughfind_manifest_package_copies_reusing), which can run with--global/--global-prefix. So check and apply see the same copies.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.
..after a symlink.canonical_or_lexicalfolded..lexically before canonicalizing. Withrepo/link -> /outside/dir, the pathrepo/link/../secret.txtpassedcontains()while the OS opens/outside/secret.txt. The function now resolves physically: the whole path throughcanonicalizewhen it exists, else component by component (each existing level canonicalized before the next..applies).utils::repo_root::tests::dot_dot_after_a_link_resolves_physically.bounded_project_pathchecked only climbing or absolute names, so-r shared/base.txtthroughshared -> ../../otherread outside. Every name is now checked on its REAL path. The fast path is onecanonicalizeof 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.utils::repo_root::tests::in_tree_names_through_an_outward_link_are_refused, coveringbounded_project_path,ProjectView::DiskandProjectView::Snapshot.pypi_requirements::tests::symlinked_requirements_refuses_wire_and_revert_without_writinglinked 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.applystopped the cargo config walk at the repository root, so a.cargo/config.tomlabove it that replaces crates.io was ignored while apply patched the registry cache.applyandapply --checknow refuse a run with Cargo patches in a Cargo project withcargo_config_outside_root(documented in CLI_CONTRACT.md). The message names the file and points at--globalfor patching the registry cache anyway.invalidate_projectwarnscargo_build_cache_stale, naming that file, because abuild.target-dir/build.build-dirset there was not read.commands::apply::tests::cargo_patches_refuse_under_a_cargo_config_above_the_repository(CLI) andutils::cargo_build_cache::tests::a_config_above_the_repository_is_named_when_its_target_dir_is_unread.apply --checkand apply could see different copies.with_store_peer_variant_copieshard-coded Workspace, butapply --checkreaches it with--global/--global-prefix. It now takes the scope. The global caller passescommon.read_scope()(ecosystem_dispatch), andvex_consumedpasses its options' scope. The body's earlier "vex's every-copy check" wording is fixed above.crawlers::npm_crawler::tests::global_scope_reads_a_yarn_store_configured_above_the_repository. Global expansion includes the store twin; workspace expansion does not.resolve_scoped,usemembers,replacedirs,module_roots) andgo_native_vendorusedReadBoundary::of, which falls back to the project directory when there is no checkout. They now useReadBoundary::upward(scope, start): the checkout in a workspace run,Unboundedwithout one or in a global run..gitdirs 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_readnow asserts the as-before answer without a checkout.vex::discover::golang::tests::gowork_naming_another_workspace_attests_nothinghas no checkout, so an out-of-projectGOWORKis again "another workspace". The checkout case is covered by the go_workspace test.pnpm-workspace.yaml.governing_workspace_filefailed closed on anypnpm-workspace.yamlabove the repository, even one in the home directory. It now applies the same never-at-or-above-home rule asmarker_above(sharedrepo_root::at_or_above_home).utils::pnpm_workspace::tests::a_workspace_file_above_the_repository_honours_the_home_rule, with home injected.parse_vendor_path_or_machine_absoluterelied onrelpath::is_anchored, which recognizesC:/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.vendor::path::tests::ownership_parse_is_root_anchored; it failed on macOS before the fix.NpmCrawlerhad two scope sources. It read bothself.scopeandoptions.read_scope(); it now has one.get_node_modules_paths,crawl_all,crawl_all_with_roots,find_by_purlsand localnode_modulesdiscovery all use the crawler's own scope, set from options at construction (for_options).debug_assert!catches a workspace crawler handed global options. It found 15crawler_npm_e2etests, oneglobal_probe_spawn_e2etest, one npm_crawler unit test and the test oracle buildingNpmCrawler::new()with global options; they now build a global crawler.for_options.uv_config_above_repo(root, ancestor)takes the caller'sfind_ancestor_uv_configanswer instead of looking it up again.go_native_vendor::Targetcarries aboundcomputed once per check (not per replace entry and file).vex::verifycomputes thenode_resolvebound once per package (not per dependency).Other open PRs that apply the repository-root rule (not changed here)
--patch-diroutside the repository protect all npm packages. This is the repo-root rule applied to a consent decision, and needs the same workspace/global scoping.pnpm-workspace.yaml/.npmrconly inside the git repository. Discovery is workspace-only, so that matches this PR'sWorkspacescope, but it should useupward_bound(ReadScope::Workspace, …)once both land.go_workspace::load, which is workspace-scoped. That is right for discovery.1. N5: vendored writers claimed a sibling checkout's vendor paths
vendor::parse_vendor_pathmatched the.socket/vendor/anchor anywhere in a string. Discovery'svendor_refalready required the path to start with that anchor. So a lock entry such aspoints 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 --revertwrote this project's recorded pre-vendor original over the other project's wiring.Fix (
vendor/path.rs):parse_vendor_pathandparse_vendor_reference(the ownership parse) now accept only a root-anchored path:.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/…frompackages/a.One function,
vendor_rest_from, now does this anchoring for both the writers and discovery'svendor_ref, so the two cannot drift apart again.parse_vendor_locator: yarn berryresolution: "left-pad@file:./.socket/…"has itsname@cut before the root-anchored parse.parse_vendor_path_or_machine_absolutecovers pnpm <= 8 only. pnpm itself writesfile: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...DepRefgains aparsefield so the shared pnpm revert uses this rule only for legacy locks.recognize_vendor_path_anywherekeeps the old match-anywhere behavior, for recognition only. Its callers never rewrite anything: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_pathcaller was reviewed:vendored_wiring_in_files): strict.recognize_vendor_path_anywhere."". 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 offind_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)returnsNotFound(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.go_workspace::resolve_with/load_with, the shared loader that #2011 makes discovery use too)go.workup to/, or any absoluteGOWORKGoWork::Invalidwith the reason, so vendored and hosted writers refuse through the existing workspace refusal and discovery withholds.usemembers outside the rootUnknownreplace => ../dirgo.mod in the module graph (read_dep_go_mod) andgo_native_vendor::expected_textProjectView::read_text/read_bytes(Disk + Snapshot), used by discovery, the lock inventory and the requirements walks:-rincludes, yarnfile:/portal:/link:copies, any..//absolute relcollect_requirements_files) and the lock flavors'pin_outside_rootrefuse withpypi_requirements_outside_root.redirect/staged.rs::read_rel), including the absolute ancestorpnpm-workspace.yaml/.npmrcprobesread <rel>: … is outside the repository root …nuget.config(nuget_config::inherited_source_keys_traced)/Inherited::outsidenames it; vendoring refuses, and hosted mode skips each NuGet dep with the new coderedirect_nuget_inherited_config_outside_root, documented in CLI_CONTRACT.mdCLI_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: theGOENVfile ($GOENV, else the OS config dir'sgo/env), used for GOWORK / GOFLAGS / GOPROXY / GOPRIVATE.crates/socket-patch-core/src/utils/go_env.rs:62gomodcache: the Go module cache. go.mod files are read from it invendor/go_workspace.rs(read_dep_go_mod),vendor/go_build_list.rs:313andvendor/go_native_vendor.rs:55.crates/socket-patch-core/src/vendor/nuget_config.rs:56user_config_path(read at:145) andsettings_chain: the userNuGet.Config(%APPDATA%/$DOTNET_CLI_HOME/~/.nuget/NuGet/NuGet.Config).crates/socket-patch-core/src/patch/redirect/npmrc.rs:564outer_config_layers: npm builtinnpmrc, user~/.npmrc/npm_config_userconfig, global<prefix>/etc/npmrc.crates/socket-patch-core/src/patch/redirect/yarnrc.rs:317RcLayout::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/.npmrcor~/.npmrc, and.bunfig.toml).crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:999global_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-178andcrates/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:619m2_repo_path_with:~/.m2/settings.xml,$MAVEN_HOME/conf/settings.xml. Alsojvm_cache.rslocal repository and Gradle cache roots.crates/socket-patch-core/src/gradle/home.rs,crawlers/gradle_cache.rs: the Gradle user homeinit.dand wrapper init scripts.crates/socket-patch-core/src/utils/repo_root.rs:33GitRepo::config_path: git's own config, which agitdir:/commondirmay 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, throughrepo_root::upward_bound(start):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.upward_boundisNoneand 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.governing_root:pnpm_lock_elsewhere,package_json_workspace_root,vlt_workspace_root(lockless npm/yarn/Bun/pnpm/vlt member)npm_lock_elsewherenames thepackage.json/pnpm-workspace.yaml/vlt.jsonabove. Hosted:redirect_workspace_lockfile_elsewhere. Vendored:vendor_lockfile_missing.member_stray_lock(a member's own npm/Bun/vlt lock)pnpm_workspace::governing_workspace_filepnpm-workspace.yamlabove fails closed as governing, without being read. Hosted:redirect_pnpm_settings_elsewhere. Vendored:vendor_pnpm_settings_elsewhere.git_branch_lockstreats an unreadgitBranchLockfileas on. The upstream pnpm restore refuses each pin and reads nothing (read_relbounds absolute upward reads the same way).uv_workspacemember walkgoverning_uv_refusal. Hosted:redirect_workspace_lockfile_elsewhere. Vendored:pypi_uv_workspace_unsupported.python_script::find_ancestor_uv_configuv_config_above_repo. Vendored uv / pylock / script:pypi_uv_ancestor_settings. Hosted: a new synthetic keyUV_ANCESTOR_OUTSIDE_KEYmakes the uv project/script (redirect_uv_*_unsupported) anduv pippylock (redirect_uv_lock_unsupported) wiring refuse.cargo::workspace_root_refusalcargo_manifest_not_workspace_root, naming theCargo.tomlabove.cargo_config::read_config_chain_with,source_replacement::config_layer_pathscargo_config::config_above_repomakes vendored refuse (user_authored_patch_entry). Hosted refuses the run with the new coderedirect_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)user_authored_patch_entry.cargo_build_cache::workspace_root(agent mode)pkg_managers.yarnrc.ymlwalksnodeLinkerreads as an unknown value, which fails closed as PnP and names the file. NopnpmStoreFolderstore is admitted.injectEnvironmentFilesdotenv files outside the checkout (other than beside the home rc) inject nothing.upstream::npm::BerryRegistrySettings::readRcLayout::files(ancestor.yarnrc/.npmrc)redirect_yarn_classic_offline_mirrornames the file.yarn_classic_workspace_membersmaven_repo::not_build_rootpom.xml, Gradle settings orbuild.sbtabove (for example the superproject of a submodule) refusesnot_build_root.gradle_cache::build_roots,vex::verify::node_resolvenode_modulescopy above read as absent, so the dependency is unresolved and not verified.Already bounded inside a checkout, so unchanged:
hatch_workspace::roots(search_dirsstops at the checkout) andvlt_project_root(stat only, stops at.git).Still not bounded, or changed in other ways
package.json,pyproject.toml,Cargo.toml,.cargo/config*,.yarnrc(.yml),.npmrc,pom.xmlor Gradle settings in a directory between the repository root and the home directory. A.yarnrc.ymlthere makes the yarn berry linker fail closed as PnP. Home and above are never checked, so a stray~/package.jsonchanges nothing. If this refuses too often, the owner could narrow the markers, for example topackage.jsonfiles that are not the repository's own.vendor/go_consumer_sync.rsmodules_txt_location: when the ancestor go.work is out of bounds,resolvenow says Invalid and this falls back to the project's ownvendor/modules.txt. The audit there may then miss staleness in the out-of-root workspace, but the write path refuses first.scan_vendor_referencestext 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.-rincludes (Vendored pip: a pin in a-rinclude outside the project root is ignored when a pylock.toml exists, sovendor --checkpasses andvexattests not_affected whilepip install -r requirements.txtinstalls 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::containsforced totrue) 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_anchoredutils::repo_root::tests::read_boundary_is_the_checkout_else_the_project,read_boundary_sees_through_linksvendor::go_workspace::tests::go_work_outside_the_repository_is_never_readvex::discover::golang::tests::gowork_naming_another_workspace_attests_nothing(expectation updated: an out-of-repoGOWORKnow says "outside the repository root")vendor::pypi_requirements::tests::includes_outside_the_repository_are_never_readvendor::nuget_config::tests::a_parent_config_above_the_repository_is_never_read,vendor::nuget_feed::tests::vendor_refuses_under_a_parent_config_above_the_repositorypatch::redirect::tests::nuget_inherited_config_outside_the_repository_skips_the_deppatch::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_boundforced toNoneand, 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_checkoutnpm_family_workspace_root_above_the_repositorymember_stray_lock_above_the_repositorypnpm_workspace_file_above_the_repository_fails_closeduv_workspace_root_above_the_repositoryuv_settings_above_the_repositorycargo_workspace_and_configs_above_the_repositorygradle_settings_above_the_repositoryyarnrc_yml_above_the_repository(linker, store andinjectEnvironmentFiles)yarn_classic_rc_above_the_repositoryvendor::cargo::tests::patch_path_outside_the_repository_is_never_readutils::cargo_build_cache::tests::workspace_root_stays_in_the_repositoryvendor::maven_repo::tests::a_build_above_the_repository_refuses_unreadpatch::redirect::yarnrc::tests::a_member_linked_from_outside_the_repository_fails_closedvex::verify::tests::node_resolve_stays_in_the_repositoryWorkspace versus global (commit 4):
utils::repo_root::tests::read_boundary_is_the_checkout_else_the_projectnow also checks thatReadScope::GlobalisUnbounded:containsis true everywhere, there is nomarker_above, andbounded_project_path/upward_bound/upward_readableare unbounded.utils::repo_root::upward_tests::global_scope_reads_above_the_repository: in a checkout with files above its root, theGlobalscope reads them (go.work,.cargo/config.toml, Gradle settings,.yarnrc.ymllinker and store), whileWorkspacestays bounded.crawlers::npm_crawler::tests::global_scope_reads_a_yarn_store_configured_above_the_repository(commit 5): a yarn project inside a git checkout, withnodeLinker: pnpm/pnpmStoreFolderin a.yarnrc.ymlabove the checkout.find_by_purls(store-only transitive copy and its twin),find_store_peer_variant_copies, the--global-prefixcrawl and the shared-store guard all see the store.node_modulestree.utils::repo_root::upward_tests::global_crawl_reads_a_package_outside_the_repository: a--global-prefixnpm crawl finds a package outside any repository while the working directory is inside one.CrawlerOptions::read_scope()isGlobalfor it andWorkspacefor 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.gitat 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):
cargo test -p socket-patch-core --lib -- vendor:: vex::discover:: patch::redirect:: hosted:: utils::repo_rootvex::discover::testing::golden(incl.committed_fixture_corpus_matches_golden)cargo test -p socket-patch-core --lib--test crawler_npm_e2e/crawlers_empty_paths_e2e/global_probe_spawn_e2e--test global_scope_project_state--test in_process_rollback_all_ecosystems/in_process_agent_reapply--lib--test vendor--test in_process_rollback_vendored--test in_process_remove_repair_lifecycle--test in_process_vendor_bun_takeover--test in_process_redirect--test in_process_rollback_hosted--test hosted_memory_engine--test in_process_scancargo clippy --locked --workspace --all-features -- -D warningscargo fmt --all -- --checkNote
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()andCrawlerOptions::read_scope()drive this everywhere the diff threads it.npm / Yarn:
NpmCrawleris 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 localnode_modulesdiscovery 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, andlive_pnp_markertakeReadScopeand stop upward rc/env reads at the repository boundary (with fail-closed linker/store behavior when an unread.yarnrc.ymlwould govern).Cargo / Go / Gradle: Cargo source replacement and config layering use scoped walks (
config_layer_paths,crates_io_source); agentapply/apply --checkrefuse Cargo patches withcargo_config_outside_rootwhen a.cargo/config*above the repo exists (stat-only detection), with JSON/human messaging and build-cache invalidation passing scope. Go workspace detection usesgo_workspace::resolve_in(Workspace, …). Gradlebuild_rootsand 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_scopethrough to the core helpers so global--global/--global-prefixmatches 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 useupward_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_abovecover 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.mddocuments bounded reads and Gogo.workoutside 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