Current Behavior
When a cached task's outputs path passes through a symlinked directory inside the workspace, restoring that task from cache deletes the symlink and creates a real directory in its place. The build artifacts land in the new local directory, and the directory the symlink pointed at is left empty and orphaned.
Only cache hits trigger it. A cache miss (task actually runs) writes through the symlink correctly, and [existing outputs match the cache, left as is] leaves it alone — so the failure appears non-deterministically from a user's point of view, on the second build.
Bisected to 23.0.2, introduced by #36116 (fix(core): prevent path traversal / zip-slip in self-hosted remote cache, commit ad296578f). It is the only commit touching packages/nx/src/native/cache/file_ops.rs in 23.0.1...23.0.2, and the behavior is explicit in the new create_dir_all_within(boundary, dir):
Ok(_) => {
// Replace a symlink/file occupying the path with a real directory.
remove_path(dir)?;
fs::create_dir(dir)
}
| version |
result |
| 22.7.5, 23.0.0, 23.0.1 |
symlink preserved |
| 23.0.2, 23.1.0, 23.1.3, 23.2.0, 23.2.1, 23.3.0-canary.20260914 |
symlink replaced with real directory |
I understand the hardening intent — a malicious artifact must not write through a symlink. The problem is that it applies to symlinks the user created in their own workspace, not just ones arriving from a cache artifact, and there is no way to opt out.
Expected Behavior
Restoring from the local cache should not modify the workspace's own directory structure. A pre-existing, user-created symlink on the path to a declared output should be written through, as in ≤ 23.0.1.
If write-through must stay blocked by default, some escape hatch would work — e.g. only apply create_dir_all_within to remote-cache restores (the threat model in #36116), honor an opt-in like nx.json → cacheRestoreFollowsSymlinks / NX_CACHE_RESTORE_FOLLOW_SYMLINKS, or at minimum fail loudly instead of silently relocating output, so the user finds out before their next build breaks.
GitHub Repo
No response
Steps to Reproduce
mkdir -p /tmp/nx-repro/pkg /tmp/nx-repro/external-build && cd /tmp/nx-repro
cat > package.json <<'EOF'
{ "name": "repro", "private": true, "version": "0.0.1", "workspaces": ["pkg"],
"devDependencies": { "nx": "23.2.1" } }
EOF
cat > nx.json <<'EOF'
{ "targetDefaults": { "build": { "cache": true } }, "neverConnectToCloud": true }
EOF
cat > pkg/package.json <<'EOF'
{ "name": "pkg", "version": "0.0.1", "private": true,
"scripts": { "build": "mkdir -p ../build/pkg && echo built > ../build/pkg/index.js" },
"nx": { "targets": { "build": { "outputs": ["{workspaceRoot}/build/pkg"] } } } }
EOF
ln -s /tmp/nx-repro/external-build build # workspace-root `build` is a symlink
npm install
npx nx run pkg:build # cache miss
ls -ld build # symlink, intact — correct
rm -rf build/pkg # force a restore
npx nx run pkg:build # cache hit, "read the output from the cache"
ls -ld build # ← now a real directory
ls external-build # ← empty; the symlink target was abandoned
Swap "nx": "23.2.1" for "23.0.1" and the final ls -ld build stays a symlink.
Nx Report
Node : 24.19.0
OS : linux-x64
Native Target : x86_64-linux
npm : 11.17.0
daemon : Available
nx : 23.2.1
Failure Logs
Package Manager Version
No response
Operating System
Additional Information
Why this matters in practice.
Many companies have internal tooling that leverage symlinks for build artifacts.
For example, Amazon uses an internal build system called Brazil which expected build/ to be a symlink. Teams at Amazon would routinely have nx emit to build/ and it be cached.
However, nx 23.0.2 in #36116 breaks this long supported artifact pattern preventing teams from adopting nx >=23.0.2.
Current Behavior
When a cached task's
outputspath passes through a symlinked directory inside the workspace, restoring that task from cache deletes the symlink and creates a real directory in its place. The build artifacts land in the new local directory, and the directory the symlink pointed at is left empty and orphaned.Only cache hits trigger it. A cache miss (task actually runs) writes through the symlink correctly, and
[existing outputs match the cache, left as is]leaves it alone — so the failure appears non-deterministically from a user's point of view, on the second build.Bisected to 23.0.2, introduced by #36116 (
fix(core): prevent path traversal / zip-slip in self-hosted remote cache, commitad296578f). It is the only commit touchingpackages/nx/src/native/cache/file_ops.rsin23.0.1...23.0.2, and the behavior is explicit in the newcreate_dir_all_within(boundary, dir):I understand the hardening intent — a malicious artifact must not write through a symlink. The problem is that it applies to symlinks the user created in their own workspace, not just ones arriving from a cache artifact, and there is no way to opt out.
Expected Behavior
Restoring from the local cache should not modify the workspace's own directory structure. A pre-existing, user-created symlink on the path to a declared output should be written through, as in ≤ 23.0.1.
If write-through must stay blocked by default, some escape hatch would work — e.g. only apply
create_dir_all_withinto remote-cache restores (the threat model in #36116), honor an opt-in likenx.json → cacheRestoreFollowsSymlinks/NX_CACHE_RESTORE_FOLLOW_SYMLINKS, or at minimum fail loudly instead of silently relocating output, so the user finds out before their next build breaks.GitHub Repo
No response
Steps to Reproduce
Swap
"nx": "23.2.1"for"23.0.1"and the finalls -ld buildstays a symlink.Nx Report
Failure Logs
Package Manager Version
No response
Operating System
Additional Information
Why this matters in practice.
Many companies have internal tooling that leverage symlinks for build artifacts.
For example, Amazon uses an internal build system called Brazil which expected
build/to be a symlink. Teams at Amazon would routinely have nx emit tobuild/and it be cached.However, nx 23.0.2 in #36116 breaks this long supported artifact pattern preventing teams from adopting nx >=23.0.2.