Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

* **On Windows, `yarn` classic installs in a project under your user profile
even when you have a `~/.yarnrc` or `~/.npmrc`.** yarn reads those files from
every directory between the project and the drive root. The sandbox does not
let a contained process read the ones in your real home, and yarn stopped on
the refusal with `EPERM: operation not permitted, open 'C:\Users\you\.yarnrc'`.
The contained process now sees those files as absent, which is what the
sandbox intends. Nothing new becomes readable. Other refused reads still
report `EPERM`.

* **On macOS, a contained install can no longer read the rest of your home
directory.** The sandbox allowed every read outside the credential stores, so
a package could read other projects in the home directory, nvx's own settings
Expand Down
202 changes: 202 additions & 0 deletions internal/nvx/sandbox_walkup_rcfile_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
//go:build windows

package nvx

// yarn classic reads .yarnrc and .npmrc from every directory between the
// project and the drive root. Under the user's profile that includes the real
// home, whose files the sandbox refuses to open, and yarn treats the resulting
// EPERM as fatal. The walk-up preload reports ENOENT for such a read, and only
// for such a read. This evaluates that in node, outside a container, by making
// the underlying fs calls refuse everything, so the test sees exactly which
// refusals the preload rewrites and which it leaves alone.

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestWalkUpShimHidesRefusedRcFilesOnlyInCoveredAncestors(t *testing.T) {
node := realNodeForTest(t)
guestHome := tempDir(t)
workDir := tempDir(t)
shim, err := writeWalkupShim(guestHome)
if err != nil {
t.Fatal(err)
}

workParent := filepath.Dir(workDir)
homeParent := filepath.Dir(guestHome)
grandParent := filepath.Dir(workParent)
if grandParent == workParent {
t.Skip("temp directories are laid out so the cases below would overlap")
}

// Refused reads that must become ENOENT: an rc file directly inside an
// ancestor of the working directory or of the home.
hidden := []string{
filepath.Join(workParent, ".npmrc"),
filepath.Join(workParent, ".yarnrc"),
filepath.Join(homeParent, ".yarnrc.yml"),
filepath.Join(workParent, ".NPMRC"),
}
// Refused reads that must stay EPERM: another name in an ancestor, an rc
// file in the chain endpoints and in unrelated places, and one nested a
// level below an ancestor.
refused := []string{
filepath.Join(workParent, "secrets.txt"),
filepath.Join(workParent, "npmrc"),
filepath.Join(workParent, ".npmrc.bak"),
filepath.Join(workDir, ".yarnrc"),
filepath.Join(guestHome, ".npmrc"),
filepath.Join(workParent, "sibling", ".npmrc"),
`C:\Windows\System32\.yarnrc`,
}
// A hidden rc file the process can open: it exists, and must keep saying so.
readable := filepath.Join(grandParent, ".yarnrc.yml")

probe := filepath.Join(workDir, "rcfile.js")
script := `
const fs = require('fs');
const fsp = fs.promises;
const readable = process.argv[3];
const eperm = (p) => Object.assign(
new Error("EPERM: operation not permitted, open '" + p + "'"),
{ code: 'EPERM', syscall: 'open', path: p });
const probed = new Set(JSON.parse(process.argv[4]));
// The sandbox's refusal, as the fs functions deliver it, for the probed paths
// only. Installed before the preload loads, so the preload wraps these.
const readFileSync = fs.readFileSync;
const readFile = fs.readFile;
const readFilePromise = fsp.readFile;
const existsSync = fs.existsSync;
const exists = fs.exists;
const openSync = fs.openSync;
const closeSync = fs.closeSync;
fs.readFileSync = (p, ...r) => { if (probed.has(p)) throw eperm(p); return readFileSync(p, ...r); };
fs.readFile = (p, ...r) => {
if (!probed.has(p)) return readFile(p, ...r);
const cb = r.pop();
process.nextTick(() => cb(eperm(p)));
};
fsp.readFile = async (p, ...r) => { if (probed.has(p)) throw eperm(p); return readFilePromise(p, ...r); };
fs.existsSync = (p) => probed.has(p) || existsSync(p);
fs.exists = (p, cb) => (probed.has(p) ? process.nextTick(() => cb(true)) : exists(p, cb));
fs.openSync = (p, ...r) => {
if (!probed.has(p)) return openSync(p, ...r);
if (p === readable) return -1;
throw eperm(p);
};
fs.closeSync = (fd) => (fd === -1 ? undefined : closeSync(fd));
require(process.argv[2]);

(async () => {
const answer = {};
for (const p of JSON.parse(process.argv[4])) {
const a = {};
try { fs.readFileSync(p); a.sync = 'ok'; } catch (e) { a.sync = e.code; }
a.cb = await new Promise((res) => fs.readFile(p, 'utf8', (e) => res(e ? e.code : 'ok')));
a.promise = await fsp.readFile(p).then(() => 'ok', (e) => e.code);
a.existsSync = fs.existsSync(p);
a.existsCb = await new Promise((res) => fs.exists(p, res));
answer[p] = a;
}
console.log('RESULT ' + JSON.stringify(answer));
})();
`
if err := os.WriteFile(probe, []byte(script), 0o600); err != nil {
t.Fatal(err)
}

all := append(append(append([]string{}, hidden...), refused...), readable)
encoded, _ := json.Marshal(all)
cmd := exec.Command(node, probe, shim, readable, string(encoded))
cmd.Dir = workDir
cmd.Env = append(os.Environ(), "USERPROFILE="+guestHome, "HOME="+guestHome, "NODE_OPTIONS=")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("evaluating the preload failed: %v\n%s", err, out)
}
line := strings.TrimSpace(string(out))
if i := strings.LastIndex(line, "RESULT "); i >= 0 {
line = line[i+len("RESULT "):]
}
type answer struct {
Sync, Cb, Promise string
ExistsSync, ExistsCb bool
}
raw := map[string]map[string]any{}
if err := json.Unmarshal([]byte(line), &raw); err != nil {
t.Fatalf("could not read the preload's answers: %v\n%s", err, out)
}
read := func(p string) answer {
m := raw[p]
s := func(k string) string { v, _ := m[k].(string); return v }
b := func(k string) bool { v, _ := m[k].(bool); return v }
return answer{s("sync"), s("cb"), s("promise"), b("existsSync"), b("existsCb")}
}

for _, p := range hidden {
a := read(p)
if a.Sync != "ENOENT" || a.Cb != "ENOENT" || a.Promise != "ENOENT" {
t.Errorf("a refused read of %s should report ENOENT, got sync=%s callback=%s promise=%s "+
"(yarn stops on the EPERM)", p, a.Sync, a.Cb, a.Promise)
}
if a.ExistsSync || a.ExistsCb {
t.Errorf("%s cannot be opened, so it should not be reported as existing "+
"(yarn checks fs.exists before it reads ~/.npmrc): existsSync=%v exists=%v",
p, a.ExistsSync, a.ExistsCb)
}
}
for _, p := range refused {
a := read(p)
if a.Sync != "EPERM" || a.Cb != "EPERM" || a.Promise != "EPERM" {
t.Errorf("a refused read of %s must stay EPERM, got sync=%s callback=%s promise=%s. "+
"The preload would be hiding a refusal outside the one place yarn looks.",
p, a.Sync, a.Cb, a.Promise)
}
if !a.ExistsSync || !a.ExistsCb {
t.Errorf("exists for %s changed: existsSync=%v exists=%v", p, a.ExistsSync, a.ExistsCb)
}
}
a := read(readable)
if !a.ExistsSync || !a.ExistsCb {
t.Errorf("%s can be opened, so it must still be reported as existing: existsSync=%v exists=%v",
readable, a.ExistsSync, a.ExistsCb)
}
}

// Opt-in verification (NVX_PROBE=1): a .yarnrc in an ancestor of the working
// directory is refused with EPERM inside an AppContainer, as yarn meets the
// one in the real home, and the preload turns that into ENOENT. The file is
// created in the temp directory, which is an ancestor of the probe's working
// directory, and removed afterwards.
func TestWalkUpShimHidesRefusedRcFileInContainer(t *testing.T) {
run := walkupProbe(t, "nvx.sandbox.rcfile.probe", `
const fs = require('fs');
const path = require('path');
const rc = path.join(path.dirname(process.cwd()), '.yarnrc');
let out;
try { fs.readFileSync(rc); out = 'ok'; } catch (e) { out = e.code; }
fs.writeFileSync(process.argv[2], out);
`)
rc := filepath.Join(os.TempDir(), ".yarnrc")
if _, err := os.Stat(rc); err == nil {
t.Skipf("%s already exists and is not this test's to remove", rc)
}
if err := os.WriteFile(rc, []byte("# nvx probe\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Remove(rc) })

without := run(false)
if without != "EPERM" {
t.Skipf("premise not met: the read of %s gave %q without the preload, so there is no refusal to rewrite", rc, without)
}
if with := run(true); with != "ENOENT" {
t.Fatalf("with the preload, the refused read of %s gave %q, and yarn stops on anything but ENOENT", rc, with)
}
}
105 changes: 105 additions & 0 deletions internal/nvx/sandbox_walkup_shim.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
// to the original function if anything at all goes wrong, because this file is
// injected into every node process in the sandbox and must never be the reason
// one fails.
//
// The same ancestors hold one more thing a tool walks up to read: yarn classic
// reads a .yarnrc from every directory between the project and the drive root,
// and the sandbox refuses the one in the real home on purpose. See
// isHiddenRcFile.

try {
const fs = require('fs');
Expand Down Expand Up @@ -59,6 +64,38 @@ try {
return !!e && (e.code === 'EPERM' || e.code === 'EACCES');
}

// yarn classic reads .yarnrc and .npmrc (and their .yml forms) from every
// directory between the project and the drive root. Under the user's profile
// that includes the real home, which the sandbox does not let a contained
// process read, and yarn treats EPERM there as fatal where ENOENT means "no
// file":
//
// Error: EPERM: operation not permitted, open 'C:\Users\<name>\.yarnrc'
//
// Measured 2026-10-06 with yarn 1.22.22 under Node 22.23.3: the stack ends in
// parseRcPaths, which skips ENOENT and EISDIR and rethrows everything else.
// The same failure follows for ~/.npmrc once ~/.yarnrc is answered.
// The sandbox hides those files on purpose, so to the contained process they
// do not exist. A refused read of one therefore reports ENOENT. Nothing
// becomes readable, only the error code changes. The rule covers only these
// rc names directly inside an ancestor of the working directory or home,
// the one place yarn looks that the sandbox hides. Any other refused read, of
// any other name or place, keeps its EPERM.
function isHiddenRcFile(p) {
if (typeof p !== 'string') return false;
if (!/^\.(yarn|npm)rc(\.yml)?$/i.test(path.basename(p))) return false;
return isCoveredAncestor(path.dirname(p));
}

function enoentFor(p) {
const err = new Error("ENOENT: no such file or directory, open '" + p + "'");
err.errno = -4058;
err.code = 'ENOENT';
err.syscall = 'open';
err.path = p;
return err;
}

// Exported so the narrowness this file claims can be asserted rather than
// merely stated. Loading via `--require` ignores module.exports; a test
// requires the file directly and checks the two predicates against paths that
Expand All @@ -68,6 +105,7 @@ try {
if (typeof module === 'object' && module.exports) {
module.exports.isCoveredAncestor = isCoveredAncestor;
module.exports.isPermissionError = isPermissionError;
module.exports.isHiddenRcFile = isHiddenRcFile;
}

// A real directory's Stats, borrowed from one the sandbox can read, so the
Expand Down Expand Up @@ -228,6 +266,73 @@ try {
});
}
};

// Reads of a hidden rc file report ENOENT. See isHiddenRcFile.
const readFileSyncOrig = fs.readFileSync;
fs.readFileSync = function (p, ...rest) {
try {
return readFileSyncOrig.call(this, p, ...rest);
} catch (e) {
if (isPermissionError(e) && isHiddenRcFile(p)) throw enoentFor(p);
throw e;
}
};

const readFileOrig = fs.readFile;
fs.readFile = function (p, ...rest) {
const cb = typeof rest[rest.length - 1] === 'function' ? rest.pop() : null;
if (!cb) return readFileOrig.call(this, p, ...rest);
return readFileOrig.call(this, p, ...rest, (err, data) => {
if (isPermissionError(err) && isHiddenRcFile(p)) return cb(enoentFor(p));
cb(err, data);
});
};

const readFilePromiseOrig = fsp.readFile;
fsp.readFile = async function (p, ...rest) {
try {
return await readFilePromiseOrig.call(this, p, ...rest);
} catch (e) {
if (isPermissionError(e) && isHiddenRcFile(p)) throw enoentFor(p);
throw e;
}
};

// yarn asks fs.exists before it reads ~/.npmrc, and the sandbox answers true
// for a file it then refuses to open (measured 2026-10-06: stat, access and
// exists succeed on the real home's .npmrc and .yarnrc, the read gives EPERM).
// A file reported present and then missing would fail yarn the same way, so
// the existence check agrees with the read: a hidden rc file that cannot be
// opened does not exist. One that can be opened still does.
function isReadRefused(p) {
try {
fs.closeSync(fs.openSync(p, 'r'));
return false;
} catch (e) {
return isPermissionError(e);
}
}

const existsSyncOrig = fs.existsSync;
fs.existsSync = function (p) {
const found = existsSyncOrig.call(this, p);
return found && isHiddenRcFile(p) && isReadRefused(p) ? false : found;
};

const existsOrig = fs.exists;
if (typeof existsOrig === 'function') {
const exists = function (p, cb) {
if (typeof cb !== 'function') return existsOrig.call(this, p, cb);
return existsOrig.call(this, p, (found) => {
cb(found && isHiddenRcFile(p) && isReadRefused(p) ? false : found);
});
};
// util.promisify(fs.exists) resolves with the boolean through this hook.
if (existsOrig[require('util').promisify.custom]) {
exists[require('util').promisify.custom] = (p) => new Promise((resolve) => exists(p, resolve));
}
fs.exists = exists;
}
} catch (e) {
// Never the reason a contained process fails.
}
Loading