Skip to content

Windows OpenSSH: 'uv trampoline failed to spawn Python child process' (os error 2) / WinError 448 untrusted mount point #61557

Description

@clankerbot99

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-tuiui-tui, @hermes/inkui-tui/packages/hermes-ink, etc.).

3. Root cause (mechanism)

  1. 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.
  2. 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.
  3. 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").
  4. 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

  1. Windows 10/11, enable OpenSSH Server feature, add an admin user.
  2. Install Hermes (uv-managed venv) — creates the uv\python\... junction.
  3. ssh adminuser@host "hermes --version"uv trampoline failed to spawn Python child process.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium — degraded but workaround existscomp/cliCLI entry point, hermes_cli/, setup wizardplatform/windowsNative Windows-specific behavior or breakagesweeper:risk-platform-windowsSweeper risk: may break or behave differently on native Windowstype/bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions