nx
Nx
GitHub
Nx
Workspace
GitHub
CI Pipeline Executions
Current execution
Succeeded
master
Timeline
Configuration
Self-Healing CI
Project Graph
Resource Usage
Analysis
Conformance
Runs
Current run
Succeeded
Tasks
Resource Usage
Insights
Compare tasks
Analytics
Sign in
Toggle sidebar
fix(core): restrict daemon and plugin worker socket access to the owning user (#36370) ## Current Behavior The Nx daemon and isolated plugin workers communicate over unix domain sockets (named pipes on Windows). On POSIX the socket directory and files are created under the ambient umask with no explicit mode, and plugin worker sockets sit directly in the shared system temp root. Socket paths are also unpredictable: each process derives its own hashed directory under `os.tmpdir()`, so there is no single path a sandbox or container policy can allowlist. The native binding is copied into a file cache before being loaded. That cache lives in a hash-named directory under the system temp root, and the loader accepts an existing entry on byte size alone, so a rebuild that happens to produce an identical size is not picked up. Separately, a daemon error is tagged as internal only when its log file can be read, so a daemon that fails *before* ever writing a log aborts the command instead of falling back to a daemonless graph build. ## Expected Behavior ### Layout on macOS and Linux Every runtime artifact moves under one fixed root, with a per-user directory beneath it. `<tmp>` is `os.tmpdir()` — `/var/folders/…` on macOS, `/tmp` on Linux: | Today | After | | --- | --- | | `<tmp>/<hash20>/d.sock` | `/tmp/.nx/<uid>/sockets/<hash20>/d.sock` | | `<tmp>/<hash20>/fp<pid>-<n>.sock` | `/tmp/.nx/<uid>/sockets/<hash20>/fp<pid>-<n>.sock` | | `<tmp>/plugin<pid>-<n>-<perfNow>.sock` | `/tmp/.nx/<uid>/sockets/<hash8>/p<pid>-<n36>-<rand8>.sock` | | `<tmp>/nx-native-file-cache-<hash7>/<file>` | `/tmp/.nx/<uid>/native-cache/<nxVersion>/<file>` | The root is a literal `/tmp/.nx` rather than `os.tmpdir()`, which honors `$TMPDIR` — per-user on macOS, rewritten by sandboxes, and stripped from the daemon's environment. A fixed path is identical on every machine, so a sandbox allowlist entry for it can be committed and shared with a team. **The uid sits directly beneath the container**, so `/tmp/.nx` is the only level that can ever be shared between users, and `sockets` and `native-cache` are inside the per-user directory rather than beside it. That is what makes a single `install -d -m 1777 -o root -g root /tmp/.nx` sufficient provisioning: there is no `/tmp/.nx/sockets` for the first user to create and own. When the container cannot be used, sockets move to `~/.nx/sockets` before falling back into the workspace. Home needs no administrator — there is no shared level to own — so a peer holding `/tmp/.nx` no longer costs the short, fixed-length socket path. **The native cache deliberately gets no second tier.** Sockets need *somewhere* to live or the daemon and plugin workers cannot run at all, which is what justifies chaining locations for them. The cache is an optimisation with a working fallback already in hand — load the binding in place from `node_modules` — so a second location would add a directory to guard and a `.node` to trust for no capability that is otherwise lost. Skipping it is also the fail-closed answer: the only thing worse than no cache is a cache another user can write to. The home tier is skipped when `HOME` makes `~/.nx` the shared container itself (`HOME=/tmp`); otherwise a tier-1 failure would point the owner-only guard at `/tmp/.nx` and take a root-owned `1777` directory to `0700`. ### Layout on Windows Named pipes are not filesystem objects, so there is nothing to allowlist and no shared directory to separate users in, and `%TMP%` is already per-user. Neither reason for the shared root applies, so sockets stay directly under `%TMP%` and **no socket path is longer than it is today**: | Today | After | | --- | --- | | `%TMP%\<hash20>\d.sock` | `%TMP%\<hash20>\d.sock` (unchanged) | | `%TMP%\<hash20>\fp<pid>-<n>.sock` | `%TMP%\<hash20>\fp<pid>-<n>.sock` (unchanged) | | `%TMP%\plugin<pid>-<n>-<perfNow>.sock` | `%TMP%\<hash8>\p<pid>-<n36>-<rand8>.sock` (3 chars shorter) | | `%TMP%\nx-native-file-cache-<hash7>\<file>` | `%TMP%\.nx\native-cache\<nxVersion>\<file>` | There is no per-uid segment on Windows — `%TMP%` is already per-user, and the segment would only spend path budget. This matters because `assertValidSocketPath` rejects paths over 95 characters and has no platform guard, so it applies to named pipes too, and `%TMP%` already contains the username. Longest username that still fits, with the default `%TMP%`: | socket | today | after | | --- | --- | --- | | daemon | ≤39 | ≤39 | | plugin | ≤30 | ≤33 | | forked / pty | ≤29 | ≤29 | The native cache is not subject to that limit. ### Shared roots `/tmp/.nx` is `1777` sticky, like `/tmp` itself, so every user on a machine can create their own directory beneath it. It is the **only** shared level: `/tmp/.nx/<uid>` and everything under it is `0700`, created and re-checked on every use. Before creating anything beneath a shared root, Nx checks that it is a real directory, that it carries the sticky bit if anyone beyond its owner can write to it, and that it belongs to the current user or to `root`. The ownership half is not redundant: sticky restricts renaming an entry to that entry's owner *and the directory's owner*, so a root belonging to another regular user is unsafe at any mode. `/tmp` itself is safe for the same reason — `root` owns it. Several users therefore share the container only when something trusted created it first — a container image, or an administrator running `sudo install -d -m 1777 -o root -g root /tmp/.nx`. That one command is the whole remedy, because no descendant is shared. Otherwise everyone after the first moves to `~/.nx`, which needs no provisioning at all; the error names the `chown` that would restore sharing. On a single-user machine, in a container, and in a sandbox, none of this applies: the first Nx run creates the container and owns it, so `isSafeSharedRoot` accepts it with no setup. `NX_SOCKET_DIR` still overrides the socket location and is used as given — it names the socket directory itself. Pointing it at one of Nx's own roots throws `InvalidSocketDirConfigured` rather than silently substituting a default, with two distinct messages: the system temp dir and `/tmp/.nx` are reachable by other users, while the per-user roots are refused because Nx manages and cleans what lives beneath them. A directory *nested* under any of them is still accepted. ### Directory handling - `ensureOwnedPrivateDir` accepts a directory only if it is a real directory (`lstat`, so a symlink does not qualify), owned by the current user, and carries no group or other permissions — read and search are enough to reach a socket inside it, so `0755` is tightened to `0700` rather than accepted. A directory that fails a check it cannot repair is not used. - Shared roots are created one level at a time, so a symlink at any level is caught rather than resolved through. The verdict comes from the resulting mode rather than from whether the `chmod` succeeded, since only a root's owner can `chmod` it. - Directory modes are changed through a descriptor opened `O_NOFOLLOW | O_NONBLOCK` and confirmed with `fstat` to be a directory, rather than by classifying errnos — the errno for a given condition is not stable across flag combinations or kernels, and `O_NONBLOCK` avoids a FIFO blocking the open. - The daemon socket file is set to `0600` after `listen`. On Linux, connecting requires write permission on the socket file; on macOS/BSD the (already `0700`) directory is what applies. - Anything that cannot be established is skipped rather than approximated: sockets try `~/.nx/sockets` and then a directory inside the workspace, and the native cache is bypassed so the binding loads in place from `node_modules`. ### Message scoping Every message carries the sender's `workspaceRoot`, stamped centrally by the transport rather than by each message constructor. A receiver scoped to a different workspace refuses it: the daemon returns the mismatch and stays alive for its own workspace, and a plugin worker drops the message. This is what catches two workspaces accidentally sharing an `NX_SOCKET_DIR`. ### Daemon failure classification A daemon failure is tagged as internal whether or not a log file exists yet, so a daemon that cannot start degrades to a daemonless graph build instead of aborting. Previously the tag was set only inside the block that read the log, so a first run — exactly when startup is most likely to fail — aborted the command. A `connect EPERM`/`EACCES` is the one exception, and is tagged separately rather than as internal. It degrades the same way, but is deliberately *not* disabled until `nx reset`: a socket owned by someone else stops being there once it is removed or the machine reboots, so a sticky disable would outlive the cause and hide the fix from anyone who followed the advice. The message names the socket, both ways out, and keeps the errno on its first line — `EACCES` and `EPERM` need opposite remedies and it is the only token that tells them apart. ## Scope Socket paths, permissions, and the native binary cache only. The AI-agent and sandbox work previously folded in here is split into **#36586**, stacked on this branch (it replaces #36463, which was closed): Codex/Superset agent detection, `nx configure-ai-agents` writing the sandbox allowances, the sandbox remediation hint, and the changes that re-enable the daemon and plugin isolation inside sandboxes. On this branch both still auto-disable under a detected sandbox, exactly as on `master`. **The Rust side is deliberately not covered, and NXC-4025 is therefore only partly met.** `packages/nx/src/native/utils/socket_path.rs` derives the Nx Console socket independently — `std::env::temp_dir()` plus a workspace hash, created with `create_dir_all` at the ambient umask — and is reached from `native/ide/nx_console/messaging.rs` on every TUI run that talks to Nx Console. It also reads `NX_SOCKET_DIR`/`NX_DAEMON_SOCKET_DIR` and applies none of the refusal list added here, so a value this branch rejects is still used as-is by the Rust path. That means the ticket's "all Nx sockets under one allowlistable root" is not yet true, and the sandbox allowlist this branch emits (`/tmp/.nx` or `~/.nx`) does not cover the Nx Console socket. It is pre-existing code on a different runtime with its own path handling, so folding it in would widen this change materially; it is tracked as a follow-up instead. ## Behavior changes worth calling out - **Socket paths moved on macOS and Linux.** Anything that located Nx sockets by scanning the system temp root must look under `/tmp/.nx/<uid>/sockets`, `~/.nx/sockets`, or `NX_SOCKET_DIR` instead. On macOS this is a different filesystem, not just a different name: `$TMPDIR` is `/var/folders/…` while the new root is the literal `/tmp`. Windows socket directories are unchanged. - **The native cache directory moved on both platforms**, from `<tmp>/nx-native-file-cache-<hash7>` to `/tmp/.nx/<uid>/native-cache/<nxVersion>` (`%TMP%\.nx\native-cache\<nxVersion>` on Windows). Tooling that excluded or cleaned the old path by name needs updating. - **Previously published `NX_DAEMON_SOCKET_DIR` guidance is reversed.** The daemon docs told users to point it at a shared directory so several long-running containers could share one daemon socket. That is exactly what this change refuses, so the page now says the opposite: never share a socket directory, and give each container its own daemon. Anyone following the old documented setup will see `InvalidSocketDirConfigured` or a refused directory rather than a silent behaviour change — that is intended, but it is a documented workflow being withdrawn, not just a docs edit. - **The `0700` mode is applied to directories you already own**, not only ones Nx creates — in practice the workspace-local fallback (`<workspaceRoot>/.nx/workspace-data/d`, routinely `0755`) and an explicitly configured `NX_SOCKET_DIR`. This ends the docker-compose pattern of sharing one socket directory across containers running as different users, and today it happens without a message. - **A container owned by another regular user is no longer used.** On a multi-user machine where someone else ran Nx first, sockets move to `~/.nx/sockets` and the native binding loads in place. Pre-creating `/tmp/.nx` as `root` restores the shared layout for everyone. - **`NX_NATIVE_FILE_CACHE_DIRECTORY` and `NX_SOCKET_DIR` now behave differently on a bad value.** A socket directory Nx refuses throws `InvalidSocketDirConfigured`, because there is no safe substitute and silently relocating sockets resurfaces later as a path-length error about a directory the user never set. A native cache directory Nx refuses warns and loads the binding in place, because that fallback is complete. Both are loud; only one is fatal. - **The native cache is keyed per uid and Nx version**, with each file additionally keyed by a hash of the resolved binding path so multiple source checkouts (all reporting `0.0.1`) do not collide. A cache entry older than its source is refreshed. ## Related Issue(s) Fixes NXC-4658 Fixes NXC-4025 <!-- polygraph-session-start --> --- [View session information ↗](https://app.trypolygraph.com/orgs/6a061dcb561c062131116eca/sessions/Tighten-Nx-daemon-RPC-socket-security-0700-0600-perms-1fee7ebf) <!-- polygraph-session-end --> --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: AgentEnder <AgentEnder@users.noreply.github.com> Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>
nx-cloud record -- pnpm nx-cloud conformance:check
⌘K
Succeeded
nx-cloud record -- pnpm nx-cloud conformance:check
Click to copy
Linux
4 CPU cores
3298fd8b
master
No resource usage was collected for this run.