Skip to content

Hide every .env at launch on Windows, escape batch arguments, and let agent mode win over -y - #183

Merged
fstubner merged 6 commits into
mainfrom
fix/windows-dotenv-cap-and-batch-args
Oct 7, 2026
Merged

fstubner merged 6 commits into
mainfrom
fix/windows-dotenv-cap-and-batch-args

Conversation

@fstubner

@fstubner fstubner commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Four Windows fixes, one commit each, and a fifth commit that finishes the batch-argument fix.

Every .env present at launch is hidden, however many there are

The launch hid only the first 200 dotenv files it found, in name order. Files named to sort first, such as .env.aaa000, kept the rest readable. They can come with a project or be left by an earlier contained run. Measured on Windows 11 26300 with 220 such files beside .env.local, a contained process read .env.local (TestLaunchHidesDotenvPastTheRecordCap, NVX_PROBE=1, run against the code before: ENVLOCAL=READ).

  • The launch now hides every dotenv file it finds. The record still holds at most 200 per project, with .env, .env.local, .env.*.local, .env.production, .env.development and .env.test first. The rest are hidden without a record, so nvx grants reset cannot put them back and their sandbox entries stay removed. The launch warns when a project holds more than 200.
  • During a run the watch keeps the limit. Once the record is full it warns once, and a new file stays readable until the next launch, which hides it. A file with a record, or one the launch found, is hidden again when an editor or git replaces it. The launch hands the watch the files it found.
  • Cost, over 3000 .env files. Hiding every file, a later launch that read each one the full way took between 416 and 567 ms. Each file is now first opened with a handle that may only read its permissions, and a file already hidden goes no further. In four runs a first launch then took between 881 ms and 1.02 s, and each launch after it between 157 and 190 ms. The code before hid 200 of the 3000 files, in between 81 and 91 ms at first and between 33 and 61 ms after.
  • SECURITY.md, enforcement-matrix note 15 and the changelog entry for the Windows .env protection now describe this.

Batch-file arguments are escaped for cmd.exe

cmd.exe reads & | < > ^ ( ) as syntax outside double quotes, and expands %VAR% inside them too. The contained batch launch quoted an argument only when it held a space or a quote, and escaped a quote as \", which cmd.exe does not know. The uncontained launch used exec.Command. Go has no escaping for batch files, and its documentation leaves the command line to the caller. So on both paths x&echo.INJECTED>file ran a second command, and %PATH%, ^ and quotes reached the batch file changed.

Both launches now use the escaping Rust's standard library has used for batch files since 1.77.2, its fix for CVE-2024-24576, ported from append_bat_arg in library/std/src/sys/args/windows.rs, with /e:ON /v:OFF. An argument holding a line break is refused, because cmd.exe drops everything after it. Before, a\nb reached node as a.

TestBatchFileArgumentsRoundTrip and TestProbeBatchShimArgumentsArriveIntact (NVX_PROBE=1, through the built binary) pass 20 arguments with every metacharacter through npm's batch shim to node, contained and uncontained. Against the code before, both failed on both paths and the injected command wrote its file. The path outside the sandbox was affected in the 0.7.0 release too. With the 0.7.0 binary, nvx shim tsc "x&echo.INJECTED>file" in a project wrote the file, as you.

A later commit routes the two other places that started a program with exec.Command and the user's arguments through the same escaper. One is execBareCommand, which runs a command when nvx is already inside a sandbox session. The other is the docker provider's docker, when it is a batch file. TestBatchFileArgumentsRoundTrip covers the nested launch, and it failed on the code before. The other launches were checked and take fixed arguments or are not batch files: a resolved node asked for -v, docker info, PowerShell's $PROFILE, and icacls and CheckNetIsolation from System32. Contained launches, doctor's checks and corepack's launchers already used the escaper or start node.exe. The same commit fixes TestTheCorepackShimStartsCorepackWithTheInstallDirectory for the Windows runner, whose 8.3 temp folder now reaches the stand-in in quotes.

Agent mode wins over -y and NVX_YES

With -y beside --agent-mode, or NVX_YES beside NVX_AGENT_MODE, the pre-install checks were approved without asking. In agent mode -y, --yes and NVX_YES now approve no check, and the refusal says in one line which of them it ignored. nvx doctor, the help text, SECURITY.md and the site say so. TestAgentModeIgnoresYesAndNvxYes covers seven ways of giving them, and all seven approved on the code before.

Junctions under versions are resolved

comparablePath fell back to the path as given when filepath.EvalSymlinks failed, which it does on a junction. A junction under versions then made a file in another folder count as a runtime nvx manages. It now uses the final path from a handle, and a path it cannot resolve counts as outside the home. Uninstall's running-process check uses the same comparison. TestAJunctionUnderVersionsDoesNotMakeAnOutsideFileNvxManaged failed on the code before.

Verification

  • gofmt -l internal cmd prints nothing. go vet ./... passes for windows, linux and darwin, at each of the four commits.
  • go test ./internal/nvx -count=1 passes, also with TEMP set to an 8.3 name as on the Windows runner.
  • CI is green on the latest commit, Windows probes included (NVX_PROBE_COUNTS pass=1070 skip=8 fail=0).
  • NVX_PROBE=1 go test ./internal/nvx -count=1 passes on Windows 11 26300, with 1065 tests passing. 14 skip, for symlink privileges, Administrator rights, a non-race build, prototype switches and helper children.
  • cd site && npm run build builds 9 pages, and npm run check:content passes.

The launch hid only the first 200 dotenv files it found, in name order, so
files named to sort first left the rest readable. Measured on Windows 11
26300 with 220 files named like .env.aaa000 beside .env.local, a contained
process read .env.local. Such files can come with a project or be left by an
earlier contained run.

The launch now hides every dotenv file present, however many. It still
records at most 200 per project, with .env, .env.local, .env.*.local,
.env.production, .env.development and .env.test first, and hides the rest
without a record. nvx grants reset cannot put those back, so their sandbox
entries stay removed, and the launch warns when a project holds more than 200.

During a run the watch keeps the limit. Once the record is full it warns once
and leaves a new file readable until the next launch, which hides it. A file
with a record, or one the launch found, is hidden again when an editor or git
replaces it.

A launch now opens each file first with a handle that may only read its
permissions, and stops there for a file already hidden. Over 3000 hidden
files a later launch took between 157 and 190 ms in four runs. Reading each
file the full way, a later launch had taken between 416 and 567 ms.
…de it

cmd.exe reads & | < > ^ ( ) as syntax outside double quotes, and expands
%VAR% inside them too. The contained launch of a batch file quoted an
argument only when it held a space or a quote, and escaped a quote as \",
which cmd.exe does not know. The uncontained launch used exec.Command, and Go
has no escaping for batch files. Its documentation leaves the command line to
the caller. On both paths an argument such as x&echo.INJECTED>file ran a
second command, inside the sandbox or as the user, and arguments holding %,
^, < or quotes reached the batch file changed.

Both launches now build the command line the way Rust's standard library has
for batch files since 1.77.2, its fix for CVE-2024-24576, ported from
append_bat_arg in library/std/src/sys/args/windows.rs. cmd.exe also gets
/e:ON and /v:OFF. An argument that holds a line break is refused, because
cmd.exe drops everything after it.

TestBatchFileArgumentsRoundTrip, and TestProbeBatchShimArgumentsArriveIntact
with NVX_PROBE=1 through the built binary, pass 20 such arguments through an
npm batch shim to node, contained and uncontained. node gets each unchanged
and no second command runs. Both fail on the code before this change.
-y and NVX_YES were read before agent mode, so --agent-mode -y, or
NVX_AGENT_MODE beside NVX_YES, approved the checks without asking. Agents
pass -y by habit. With -y beside --agent-mode a release inside the
cooling-off window went ahead with "Approved without asking (-y)".

In agent mode -y, --yes and NVX_YES now approve no check, and the refusal
says in one line which of them it ignored. A package OSV lists as malicious
was already refused. Outside agent mode they approve the checks as before.
nvx doctor says agent mode ignores NVX_YES when both are set, and the help
text, SECURITY.md and the site say the same.

TestAgentModeIgnoresYesAndNvxYes covers each way of giving the two, and
fails on the code before this change.
comparablePath used filepath.EvalSymlinks and fell back to the path as given
when that failed. EvalSymlinks fails on a junction, so a junction under
versions that led to another folder made a file there count as a runtime nvx
manages. Staging was then skipped, and the sandbox would have been granted
read and execute on the folder the junction led to.

It now asks Windows for the file's final path through a handle, which
follows junctions and links and expands 8.3 names. A path it cannot resolve
counts as outside the home. The uninstall check for running processes uses
the same comparison, and leaves out an image it cannot resolve.

TestAJunctionUnderVersionsDoesNotMakeAnOutsideFileNvxManaged fails on the
code before this change.
execBareCommand, which runs a command when nvx is already inside a sandbox
session, started it with exec.Command. So a batch file found on PATH got Go's
escaping, which cmd.exe does not follow, and x&echo.INJECTED>file ran a
second command. The docker provider started "docker" the same way, with the
command's own arguments in the list, so a docker that is a batch file had the
same problem. Both now go through directExecCommand. TestBatchFileArgumentsRoundTrip
covers the nested launch, and failed on the code before this change
with the injected command writing its file.

The other launches on Windows were checked. A resolved node asked for -v,
docker info and powershell's $PROFILE take fixed arguments, so cmd.exe has
nothing of the user's to read even when one is a batch file. icacls and
CheckNetIsolation come from System32 and are not batch files. Contained
launches, doctor's checks and corepack's launchers already use the escaper or
start node.exe.

TestTheCorepackShimStartsCorepackWithTheInstallDirectory failed on the
Windows runner, whose temp folder is spelled with an 8.3 name. The ~ in it is
outside the characters left unquoted, so the stand-in corepack's raw %*
showed the directory in quotes. The stand-in now prints each argument as a
batch file reads it, without the quotes. The full suite passes with TEMP set
to an 8.3 name.

The changelog says the path outside the sandbox was affected in 0.7.0 too.
With the 0.7.0 release, nvx shim tsc "x&echo.INJECTED>file" in a project
wrote the file.
Main gave the dotenv watch an afterBatch hook for tests and a test that starts the watch through watchDotenvFiles. Both are kept beside the launch's list of files. The new test uses the dotenv handle watchDotenvFiles now returns, and the test for a replaced launch file waits as long as the others.
@fstubner
fstubner merged commit 16c71a3 into main Oct 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant