Repository navigation
Go apply writes its replace into a root go.mod that go.work doesn't use, so the workspace build links the unpatched module while apply, apply --check and VEX all report it patched #458
Description
[agent] Found by the scheduled Go modules bug-hunt routine (ledger #317).
Summary
The project root has both a go.mod and a go.work, and the go.work doesn't list the root module (for example use ./svc only, with no use .). Agent-mode apply writes replace example.com/upstream v1.0.0 => ./.socket/go-patches/… into the root go.mod and exits 0 with "1 of 1 targeted patch applied".
In workspace mode, Go only honours replace directives from go.work and from the go.mod files of the modules in its use list. A root go.mod that isn't a member is ignored completely. So every workspace build (cd svc && go build) still links the PRISTINE upstream. Despite that:
apply --checkreports "Patch redirects are in sync (1 redirect checked)", exit 0.vexattestsnot_affected, exit 0.
Positive control: with use . added to the same go.work, the identical replace takes effect and the member builds PATCHED. The defect is therefore that the go.work use set isn't consulted.
This is different from #393 (a user replace in go.work overriding ours) and #392 (a replace for a version the graph doesn't select). Here the version is selected and nothing overrides the replace; the file it was written to is simply not part of the build.
Impact
A repo whose go.work leaves out the root module (a root tooling/meta module, or a go.work that lists only the services being developed) stays vulnerable, while all three socket-patch signals (apply, the apply --check CI gate and OpenVEX) say it's patched. Nothing warns.
Repro (Linux, hermetic file GOPROXY, same fixture shape as tests/e2e_golang_build.rs)
export GOTOOLCHAIN=local GOSUMDB=off GOENV=off GOFLAGS= GOPROXY=file://$T/proxy GOMODCACHE=$T/modcache
# example.com/upstream v1.0.0: Greeting() = "PRISTINE"; .socket/manifest.json + blob patch it to "PATCHED",
# setup.manual = ["golang"]
# root/go.mod: module example.com/consumer / go 1.18 (with or without `require example.com/upstream v1.0.0`)
# root/svc/go.mod: module example.com/svc / go 1.18 / require example.com/upstream v1.0.0 ; main prints upstream.Greeting()
# root/go.work: go 1.18 / use ./svc
(cd svc && go run .) # OUT: PRISTINE
socket-patch apply --offline --ecosystems golang # exit 0, "1 of 1 targeted patch applied"
grep replace go.mod # replace example.com/upstream v1.0.0 => ./.socket/go-patches/example.com/upstream@v1.0.0
(cd svc && go run .) # OUT: PRISTINE <- still unpatched
socket-patch apply --check --ecosystems golang # "Patch redirects are in sync (1 redirect checked).", exit 0
socket-patch vex --offline --product pkg:golang/example.com/consumer --output v.json # exit 0, "status": "not_affected"
# control: printf 'go 1.18\n\nuse .\nuse ./svc\n' > go.work -> (cd svc && go run .) # OUT: PATCHEDReproduced 2× per cell on go 1.24.7, and once each on 1.22.12 and 1.26.8.
Expected vs actual
- Expected: README ("socket-patch vex") says the attestation "only covers patches that are actually applied". docs/ecosystems.md ("Go: directory replaces and go.sum") presents the go.mod
replaceas the whole mechanism, withapply --checkas "a read-only audit that the committed redirects still match". The hosted contract (crates/socket-patch-cli/CLI_CONTRACT.md, golang row) already treats an inert replace as "diagnosed, no ref". When ago.workexists and doesn'tusethe directory holding the edited go.mod,applyshould do one of two things: write the replace where the workspace honours it (go.work, or a member go.mod), or refuse/warn.apply --checkandvexshould treat that replace as inert. - Actual: exit 0 everywhere, and the build is unpatched.
OS × version
| OS | go | agent apply |
apply --check |
vex |
|---|---|---|---|---|
| Linux | 1.22.12 | fail (unpatched, exit 0) | in sync | not_affected |
| Linux | 1.24.7 | fail | in sync | not_affected |
| Linux | 1.26.8 | fail | in sync | not_affected |
| Linux | 1.18 | not run (dl.google.com is blocked in the sandbox; the proxy toolchain module only exists for 1.21+) | ||
| macOS / Windows | any | not run (probe branches are blocked this run; Windows apply is also blocked by #346) |
Vendored mode (vendor) couldn't be exercised: v5 vendor needs the patch service (--vendor-source=service), which this sandbox can't reach. It writes the same root go.mod replace, so it's probably affected too (unverified).
First bad version: not a regression. Release 4.0.0 behaves the same, and so does main 2463257.
Suspect code
crates/socket-patch-core/src/patch/redirect/golang_local.rs:169apply_go_redirect→go_mod_edit::ensure_replace_entry: always edits the rootgo.mod, and never readsgo.workor itsusedirectives.crates/socket-patch-core/src/patch/redirect/golang_local.rs:452verify_go_redirect_state(apply --check): doesn't check that the root module is a workspace member whengo.workexists.crates/socket-patch-core/src/vex/discover/golang.rs:113-120: root go.mod replaces are extracted and checked against the rootrequireeven when the siblinggo.workexcludes.; the same membership gap applies to manifest-less hosted and vendored VEX.
Backlog review — 2026-10-08
Priority: P2 → P1. An inert go.mod outside the go.work selection yields a successful but false safety attestation for the workspace build.
Activity
go: run 4 ledger (#458, re-triage #343 #392)
mikolalysenko commentedon Oct 1, 2026
[agent] Triaged as priority:p2 (Go). Shares root cause with #393: the agent-mode Go redirect (
patch/redirect/golang_local.rsapply_go_redirect/verify_go_redirect_state) never readsgo.work. It always edits the root go.mod and treats that replace as effective, whatever the workspace'suseset (this issue) or a go.workreplace(#393) says. Will be fixed together. Not a duplicate of #392/#393: here the version is selected and nothing overrides the replace.Generated by Claude Code
mikolalysenko commentedon Oct 1, 2026
[agent] New information: hosted and vendored modes are affected too. Each reproduced 2× on main
61cfb9b(Linux, go 1.24.7). With this, all three Go modes are confirmed.The fixture: the root has go.mod + go.sum,
svc/is a member requiringexample.com/upstream v1.0.0, andgo.workisgo 1.21 / use ./svc(nouse .). The patch API is a local mock in the shapes ofe2e_golang_hosted_build.rs(hosted:view/<uuid>+/patches/packagewith agoproxyoverride) andvendor/golang.rsmount_go_granted(vendored: a granted tarball withsha512integrity).Hosted:
Vendored:
So the hosted rewriter (
redirect/mod.rsgolang arm),vendor/golang.rsvendor_go_module, and the manifest-less VEX discovery (vex/discover/golang.rs:113-120) all have the same go.work membership gap as the agent path.Generated by Claude Code
mikolalysenko commentedon Oct 2, 2026
[agent] Shares root cause with #393, #531: the Go redirect never reads the enclosing
go.work. #531 adds a third symptom: applying in a second workspace member writes a conflicting per-member replace, which breaks the workspace build. Will be fixed together.Generated by Claude Code
mikolalysenko commentedon Oct 9, 2026
v5 triage: P2, not a release blocker. Retain Go workspaces whose root module is excluded from go.work at P2; ordinary member-root lockfile patching takes priority.
This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.
mikolalysenko commentedon Oct 11, 2026
[agent] Claiming this issue (with #393, #531; shared root cause: the Go replace writers,
apply --check/vendor --checkand VEX never consult the go.work in effect). Branch: agent/fix-go-work-blind-redirect. Claim-ID: 2026-10-11T16:21:16Z-856761Generated by Claude Code
mikolalysenko commentedon Oct 11, 2026
[agent] Draft PR: #1393
Generated by Claude Code
Refuse Go replaces that go.work defeats
Leave go.work-defeated Go patches out of VEX