Windows OpenSSH + uv/npm reparse-point traversal failure
Audience: maintainers of uv (astral-sh/uv), the Hermes Agent installer/runtime, npm/pnpm, and Win32-OpenSSH.
Goal: explain the precise failure mechanism and concrete upstream fixes so Windows-OpenSSH users stop hitting it.
1. Problem statement
When a user runs Hermes Agent (or any tool backed by a uv-managed Python virtualenv, or any Node project with workspace symlinks) over Windows OpenSSH Server, the process fails with one of:
error: uv trampoline failed to spawn Python child process / Caused by: entity not found (os error 2), or
OSError: [WinError 448] The path cannot be traversed because it contains an untrusted mount point when the app stat()s a path inside node_modules.
The same command works perfectly in a local PowerShell/cmd session on the same machine.
2. Environment
- Windows 10/11 with the OpenSSH Server Windows Feature enabled.
- Target user is a member of the Administrators group (the norm on single-user dev machines).
- Hermes Agent v0.18.0, installed via the Hermes installer which uses uv (
uv = 0.11.14) to provision a managed CPython 3.11 venv.
- uv stores the interpreter at
C:\Users\<user>\AppData\Roaming\uv\python\cpython-3.11-windows-x86_64-none, which is a junction pointing at the concrete version dir cpython-3.11.15-windows-x86_64-none.
- The Hermes project uses an npm/pnpm workspace;
node_modules contains junctions (hermes-tui → ui-tui, @hermes/ink → ui-tui/packages/hermes-ink, etc.).
3. Root cause (mechanism)
- OpenSSH on Windows creates the session with a network-type logon token. For accounts that are members of Administrators, Windows applies UAC remote restriction (token filtering): the network token is a filtered token missing several privileges a local interactive logon would carry.
- Windows reparse points (junctions and symlinks) are subject to a traversal/trust check. Traversing a junction requires the caller's token to be "trusted" for the target. A local interactive token passes; a filtered network token fails with
STATUS_UNTRUSTED_MOUNT_POINT (0x80070498 / WinError 448). This is not about ACL/file permissions — it is about the token type/privileges.
- uv's standalone trampoline (
hermes.exe, a ~46 KB uv binary) resolves the interpreter path through the uv\python\... junction. Under the filtered token the spawn of python.exe through the junction fails with os error 2 (the kernel returns the untrusted-mount status, surfaced by Rust std::process as "entity not found").
- The Hermes
chat command does os.stat("node_modules/@hermes/ink/package.json") to detect the TUI. @hermes/ink is an npm workspace junction; the stat fails with WinError 448 under the filtered token.
The two failures share the same underlying cause (filtered network token + reparse point) but surface at different code paths. There is also a third, independent gotcha: because the user is an admin, OpenSSH-Win's default sshd_config (Match Group administrators → AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys) makes sshd ignore ~/.ssh/authorized_keys, so key auth is refused and the client falls through to a password prompt (on a Mac client this is a password dialog; on Windows-side loopback testing it is a git-bash prompt).
4. Why local works but SSH doesn't (the key insight)
It is not missing environment variables. We dumped the SSH session environment and confirmed APPDATA, LOCALAPPDATA, USERPROFILE, PATH, HOME are all correctly set. The differentiator is purely the logon token type: local interactive (trusted → can traverse reparse points) vs. SSH network (filtered → cannot).
5. User-side fix (what we applied)
Set HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\LocalAccountTokenFilterPolicy = 1. This disables UAC remote restriction for local admin accounts, so an SSH admin logon receives a full (non-filtered) token that can traverse reparse points. Effect is per new logon (reconnect SSH; no reboot required). After this, both the uv trampoline spawn and the node_modules stat succeed over SSH.
We confirmed the fix is durable: hermes chat -q runs the "Installing TUI dependencies…" step which regenerates the npm junctions, and the full token traverses them without error — so the fix does not need to be re-applied when the app rewrites its node_modules.
(Alternative user-side fix with a stronger security posture: use a dedicated non-admin Windows account for SSH. Non-admin logons are not subject to the filtered-token restriction and traverse reparse points normally. This also sidesteps the administrators_authorized_keys requirement.)
6. Recommendations for upstream fixes
uv (astral-sh/uv) — primary offender for the Python spawn
- Do not install the managed interpreter as a junction. The version-keyed directory (
cpython-3.11-windows-x86_64-none) is a junction to the concrete version (cpython-3.11.15-...). On Windows, prefer a real directory copy, or store the resolved absolute path and skip the indirection entirely. (A symlink has the same traversal restriction for filtered network tokens, so it is not a fix.)
- In the standalone trampoline (
uv self-contained exe used as a venv launcher), resolve the interpreter path to its final, non-reparse target before CreateProcess. GetFinalPathNameByHandle / DeviceIoControl(FSCTL_GET_REPARSE_POINT) resolve the junction to the real path; spawning through the resolved path avoids the kernel traversal check entirely.
- Surface a better error. When the spawn fails with
STATUS_UNTRUSTED_MOUNT_POINT, detect it and emit actionable guidance (e.g., "the Python interpreter is behind a reparse point the current logon token cannot traverse; if connecting over SSH/remote desktop, see …").
Hermes installer / runtime
- Detect the SSH/admin scenario at install time and either (a) provision the managed Python as a real directory rather than a junction, (b) document and optionally offer to set
LocalAccountTokenFilterPolicy=1, or (c) recommend a non-admin service account.
- The
chat command's TUI detection stat()s a path behind an npm workspace junction. Resolve the real path first (e.g., os.path.realpath / Path.resolve()) before stat-ing, or ship the build without workspace junctions (flatten node_modules). This makes the runtime robust regardless of token type.
- Ship a Windows SSH troubleshooting note covering both the reparse-point/token issue and the
administrators_authorized_keys requirement for admin users.
npm / pnpm (workspace symlinks)
- Workspace links are created as junctions on Windows — exactly the reparse points that break under filtered network tokens. There is no easy per-tool fix short of using real copies for the linked packages, but documenting the interaction (and that
LocalAccountTokenFilterPolicy=1 or a non-admin account resolves it) would save many users.
OpenSSH-Win (PowerShell/Win32-OpenSSH)
- The
Match Group administrators → AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys default is a well-known footgun. Consider documenting it more prominently, or warning at service start if an admin user's ~/.ssh/authorized_keys is being ignored.
7. Minimal reproduction
- Windows 10/11, enable OpenSSH Server feature, add an admin user.
- Install Hermes (uv-managed venv) — creates the
uv\python\... junction.
ssh adminuser@host "hermes --version" → uv trampoline failed to spawn Python child process.
- Set
LocalAccountTokenFilterPolicy=1, reconnect, repeat → succeeds.
(For diagnosis without a second machine, the same error reproduces via loopback ssh user@localhost using a throwaway ed25519 key placed in administrators_authorized_keys for admin users.)
8. Appendix — commands used in diagnosis/fix
# Make SSH admin logon a full (non-filtered) token — the durable fix
$key = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System"
New-ItemProperty -Path $key -Name "LocalAccountTokenFilterPolicy" -Value 1 -PropertyType DWord -Force
(Get-ItemProperty $key).LocalAccountTokenFilterPolicy # -> 1
# Admin SSH key (SYSTEM-owned file; write elevated)
$pk = (Get-Content "C:\Users\<user>\.ssh\id_ed25519.pub").Trim()
Add-Content -Path "C:\ProgramData\ssh\administrators_authorized_keys" -Value $pk
icacls "C:\ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant:r "SYSTEM:F" /grant:r "BUILTIN\Administrators:F"
# Authoritative reparse check (PowerShell's -Recurse cache lies; use Python)
# bool(os.stat(path, follow_symlinks=False).st_file_attributes & 0x400)
Error signatures seen:
error: uv trampoline failed to spawn Python child process
Caused by: entity not found (os error 2)
OSError: [WinError 448] The path cannot be traversed because it contains an untrusted mount point
Windows OpenSSH + uv/npm reparse-point traversal failure
Audience: maintainers of
uv(astral-sh/uv), the Hermes Agent installer/runtime, npm/pnpm, and Win32-OpenSSH.Goal: explain the precise failure mechanism and concrete upstream fixes so Windows-OpenSSH users stop hitting it.
1. Problem statement
When a user runs Hermes Agent (or any tool backed by a uv-managed Python virtualenv, or any Node project with workspace symlinks) over Windows OpenSSH Server, the process fails with one of:
error: uv trampoline failed to spawn Python child process/Caused by: entity not found (os error 2), orOSError: [WinError 448] The path cannot be traversed because it contains an untrusted mount pointwhen the appstat()s a path insidenode_modules.The same command works perfectly in a local PowerShell/cmd session on the same machine.
2. Environment
uv = 0.11.14) to provision a managed CPython 3.11 venv.C:\Users\<user>\AppData\Roaming\uv\python\cpython-3.11-windows-x86_64-none, which is a junction pointing at the concrete version dircpython-3.11.15-windows-x86_64-none.node_modulescontains junctions (hermes-tui→ui-tui,@hermes/ink→ui-tui/packages/hermes-ink, etc.).3. Root cause (mechanism)
STATUS_UNTRUSTED_MOUNT_POINT(0x80070498/WinError 448). This is not about ACL/file permissions — it is about the token type/privileges.hermes.exe, a ~46 KBuvbinary) resolves the interpreter path through theuv\python\...junction. Under the filtered token the spawn ofpython.exethrough the junction fails withos error 2(the kernel returns the untrusted-mount status, surfaced by Ruststd::processas "entity not found").chatcommand doesos.stat("node_modules/@hermes/ink/package.json")to detect the TUI.@hermes/inkis an npm workspace junction; thestatfails withWinError 448under the filtered token.The two failures share the same underlying cause (filtered network token + reparse point) but surface at different code paths. There is also a third, independent gotcha: because the user is an admin, OpenSSH-Win's default
sshd_config(Match Group administrators → AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys) makes sshd ignore~/.ssh/authorized_keys, so key auth is refused and the client falls through to a password prompt (on a Mac client this is a password dialog; on Windows-side loopback testing it is a git-bash prompt).4. Why local works but SSH doesn't (the key insight)
It is not missing environment variables. We dumped the SSH session environment and confirmed
APPDATA,LOCALAPPDATA,USERPROFILE,PATH,HOMEare all correctly set. The differentiator is purely the logon token type: local interactive (trusted → can traverse reparse points) vs. SSH network (filtered → cannot).5. User-side fix (what we applied)
Set
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\LocalAccountTokenFilterPolicy = 1. This disables UAC remote restriction for local admin accounts, so an SSH admin logon receives a full (non-filtered) token that can traverse reparse points. Effect is per new logon (reconnect SSH; no reboot required). After this, both the uv trampoline spawn and thenode_modulesstatsucceed over SSH.We confirmed the fix is durable:
hermes chat -qruns the "Installing TUI dependencies…" step which regenerates the npm junctions, and the full token traverses them without error — so the fix does not need to be re-applied when the app rewrites itsnode_modules.(Alternative user-side fix with a stronger security posture: use a dedicated non-admin Windows account for SSH. Non-admin logons are not subject to the filtered-token restriction and traverse reparse points normally. This also sidesteps the
administrators_authorized_keysrequirement.)6. Recommendations for upstream fixes
uv (astral-sh/uv) — primary offender for the Python spawn
cpython-3.11-windows-x86_64-none) is a junction to the concrete version (cpython-3.11.15-...). On Windows, prefer a real directory copy, or store the resolved absolute path and skip the indirection entirely. (A symlink has the same traversal restriction for filtered network tokens, so it is not a fix.)uvself-contained exe used as a venv launcher), resolve the interpreter path to its final, non-reparse target beforeCreateProcess.GetFinalPathNameByHandle/DeviceIoControl(FSCTL_GET_REPARSE_POINT)resolve the junction to the real path; spawning through the resolved path avoids the kernel traversal check entirely.STATUS_UNTRUSTED_MOUNT_POINT, detect it and emit actionable guidance (e.g., "the Python interpreter is behind a reparse point the current logon token cannot traverse; if connecting over SSH/remote desktop, see …").Hermes installer / runtime
LocalAccountTokenFilterPolicy=1, or (c) recommend a non-admin service account.chatcommand's TUI detectionstat()s a path behind an npm workspace junction. Resolve the real path first (e.g.,os.path.realpath/Path.resolve()) beforestat-ing, or ship the build without workspace junctions (flattennode_modules). This makes the runtime robust regardless of token type.administrators_authorized_keysrequirement for admin users.npm / pnpm (workspace symlinks)
LocalAccountTokenFilterPolicy=1or a non-admin account resolves it) would save many users.OpenSSH-Win (PowerShell/Win32-OpenSSH)
Match Group administrators → AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keysdefault is a well-known footgun. Consider documenting it more prominently, or warning at service start if an admin user's~/.ssh/authorized_keysis being ignored.7. Minimal reproduction
uv\python\...junction.ssh adminuser@host "hermes --version"→uv trampoline failed to spawn Python child process.LocalAccountTokenFilterPolicy=1, reconnect, repeat → succeeds.(For diagnosis without a second machine, the same error reproduces via loopback
ssh user@localhostusing a throwaway ed25519 key placed inadministrators_authorized_keysfor admin users.)8. Appendix — commands used in diagnosis/fix