8000 PowerShell callers lose argument quoting through wmux.cmd: `>` in any free-form argument silently redirects instead of reaching the CLI · Issue #154 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

PowerShell callers lose argument quoting through wmux.cmd: > in any free-form argument silently redirects instead of reaching the CLI #154

Closed

Description

@capad-xyz

Summary

wmux.cmd forwards its arguments with %*, which re-exposes them to cmd.exe's parser. When the caller is PowerShell, the quoting the user wrote is stripped before cmd.exe sees the line, so any >, |, & or < inside a free-form argument is interpreted as shell syntax instead of reaching the CLI.

The > case is the bad one: it silently succeeds with the wrong result and writes a stray file into the user's cwd. Exit code 0, no output, no warning.

if defined WMUX_CLI (
node "%WMUX_CLI%" %*
) else (
node "%~dp0..\cli\wmux.js" %*
)

The bash shim next to it is already correct — it uses "$@":

wmux/src/cli-bin/wmux

Lines 7 to 8 in fae21a5

CLI="${WMUX_CLI:-$(cd "$(dirname "$0")/../cli" && pwd)/wmux.js}"
exec node "$CLI" "$@"

Repro

From PowerShell, with a browser pane open (the current page's title happens to be 24 characters):

PS> wmux browser eval "document.title.length>0"
PS> $LASTEXITCODE
0

No output. It looks like a no-op. It was not:

PS> Get-Content .\0
{
  "result": 24
}

document.title.length was evaluated, and cmd.exe redirected the CLI's stdout into a new file named 0 in the working directory. The user asked a yes/no question, got silence, and gained a junk file.

Other separators, same cause:

PS> wmux browser eval "1>2"          # exit 0, silent, creates a file named "2" containing {}
PS> wmux browser eval "a|b"          # exit 255
'b' is not recognized as an internal or external command,
operable program or batch file.
PS> wmux browser eval '"x"&"y"'      # exit 1
Error: Uncaught
'"y"' is not recognized as an internal or external command,
operable program or batch file.

That last one is particularly confusing to debug — cmd.exe reports a command the user never typed, in the middle of what looks like CLI output.

It is the shim, not the CLI

Bypassing the shim works fine:

PS> node "$env:LOCALAPPDATA\Programs\wmux\resources\cli\wmux.js" browser eval "1+1"
{ "result": 2 }

And so does cmd.exe itself, because cmd does not parse redirection inside a quoted token — the quotes survive when they are typed in cmd:

C:\> wmux browser eval "document.title.length>0"
{ "result": true }

So the trigger is specifically PowerShell → .cmd → %*: PowerShell strips the quotes when invoking a native .cmd, and %* then hands the bare > straight back to cmd.exe.

Verified on v0.46.0, Windows 11, PowerShell 7. The shipped resources/cli-bin/wmux.cmd is byte-identical to src/cli-bin/wmux.cmd on master.

Scope

This is not a browser eval bug — eval is just the easiest place to see it, because it echoes its argument back. Every command declared passthrough: true takes free-form text and is exposed:

browser (type / fill / eval), send, notify, log, markdown set --content, rename-workspace, rename-surface, ssh

A couple that would bite in ordinary use:

wmux send "npm run build > out.log"     # redirects on the HOST, sends the truncated text
wmux notify "Build & deploy finished"   # cmd splits at &, tries to run "deploy finished"

wmux send is the one I would worry about most: it silently sends different text than the user wrote into a live terminal.

Why it is easy to miss

Agents drive wmux from Bash (correct shim) far more often than from PowerShell, and the failure is silent in exactly the case a human is most likely to hit — a > comparison in a quick eval. I only found it while chasing a vague "browser eval dies from PowerShell" report; the reporter's symptom was Access is denied., which is what you get when the redirect target resolves somewhere unwritable.

Fix direction

I did not want to guess at your preference here, since the robust options differ in cost:

  1. Re-quote inside the .cmd. Loop the arguments and wrap each in quotes before handing them to node. Keeps one shim, but batch argument re-quoting is genuinely fiddly (%* is raw; %1..%9 caps out and loses quoting nuances of its own).
  2. Ship a wmux.ps1 alongside, so PowerShell resolves that instead and passes @args through natively without a cmd round-trip. Cleanest for the affected caller, at the cost of a third shim to keep in step.
  3. Leave the shim and document it — I would argue against this one given the > case is silent.

Happy to implement whichever you prefer, with a test. I would lean to 2, since it removes the cmd.exe parser from the PowerShell path entirely rather than trying to out-escape it, and the bash shim already sets the precedent of one shim per shell family.

Activity

  1. amirlehmam commented on Aug 13, 2026

    @amirlehmam
    Owner

    Fixed in 0.50.0, and you were right to lean to option 2 — but for a stronger reason than "cleanest for the affected caller". Option 1 is not merely fiddly, it is impossible, and option 2 needed a gate before it was safe to ship.

    Option 1 cannot work, and it's worth knowing why

    I tried it before agreeing with you, because "re-quote inside the .cmd" is the fix that doesn't add a file. The standard robust idiom — capture each argument with %~1 while delayed expansion is off, re-quote it, invoke through !ARGS! so the expansion is never re-parsed:

    @echo off
    setlocal DisableDelayedExpansion
    set "WMUX_HERE=%~dp0"
    set /a N=0
    :collect
    if "%~1"=="" goto run
    set /a N+=1
    set "A%N%=%~1"
    shift
    goto collect
    :run
    setlocal EnableDelayedExpansion
    for /l %%i in (1,1,%N%) do set "ARGS=!ARGS! "!A%%i!""
    node "!WMUX_HERE!echo.js" !ARGS!

    From PowerShell, that still loses document.title.length>0, still creates a file named 0, still breaks a|b and x&&y. Because the redirect is applied to cmd.exe's own invocation line, before the batch file is entered — so %1 never contains a >; the argument was already gone when the shim started. Nothing inside a .cmd can recover what cmd.exe consumed on the way in. (Interestingly the cases you listed as hypothetical — notify "Build & deploy finished" — survive today, because PowerShell does quote arguments containing spaces. It's the space-free ones, the > comparison you found, that go through bare.)

    So the shell has to be removed from the path, not out-escaped. wmux.ps1, @args.

    Why the .ps1 could not just be shipped

    A .ps1 PowerShell refuses to run is a hard error, and PowerShell does not fall back to the wmux.cmd sitting beside it — resolution has already committed. Two ordinary conditions produce exactly that:

    • Execution policy. Restricted is the Windows PowerShell 5.1 default on client Windows.
    • Mark of the Web. Files extracted from a downloaded zip carry :Zone.Identifier; under RemoteSigned (the pwsh default) that is "remote and unsigned", and wmux has no chain-trusted signature to offer.

    Measured, not assumed:

    PS> pwsh -NoProfile -Command "Get-ExecutionPolicy; probe motw test"
    RemoteSigned
    probe: File …\probe.ps1 cannot be loaded. The file …\probe.ps1 is not digitally
    signed. You cannot run this script on the current system.
    

    Shipping the shim unconditionally would have traded your silent-wrong-result for a wmux that does not run at all in PowerShell. Strictly worse.

    What shipped

    src/cli-bin-ps/ holds wmux.ps1 plus a wmux-shim-probe.ps1 whose only job is to print a marker. At startup wmux strips the Mark of the Web from both (best effort), then executes the probe in every installed host — powershell.exe and pwsh.exe — from a process with any inherited -ExecutionPolicy Bypass cleared, since wmux launches its own panes with Bypass and inheriting that would make the probe pass for a machine where a fresh shell would refuse. Only if every installed host prints the marker does the dir get prepended to PATH, ahead of cli-bin. Otherwise the dir is simply never on PATH and PowerShell keeps resolving wmux.cmd exactly as it does today.

    Absent hosts don't vote; one refusal vetoes, because a user's panes and their children do not all run the same host and one refusal is one place wmux would stop working. Verified, not assumed — same principle as the fix for #152.

    The interactive wmux function in wmux-powershell-integration.ps1 already did the right thing with @args; what it could not reach is a PowerShell child of a pane (pwsh -NoProfile, which is how agents' shell tools run), and that inherits PATH but not the function. That is the population this fixes.

    Before / after, against a live instance

    # via wmux.cmd — the shipped 0.48.0 path
    PS> wmux browser eval "document.title.length>0"
    PS> $LASTEXITCODE
    0
    PS> Get-Content .\0
    { "result": 105 }
    
    # via wmux.ps1 — 0.50.0
    PS> wmux browser eval "document.title.length>0"
    { "result": true }
    PS> Test-Path .\0
    False
    

    Tests: tests/unit/powershell-shim.test.ts pins the gate (enable when every installed host runs it, refuse if any blocks scripts, refuse when nothing could be probed, refuse on output that isn't the marker), plus an end-to-end block — skipped where no PowerShell exists — that runs your exact repro through PATH resolution and asserts both the argument arriving intact and the absence of a file named 0.

    Thank you for this one. The Access is denied. symptom you chased it down from is the kind of report that usually dies unexplained.

  2. capad-xyz commented on Aug 13, 2026

    @capad-xyz
    ContributorAuthor

    0.50.0 landed on this machine today, so I re-ran the original repro against the shipped build. Confirmed fixed, on the machine the report came from:

    === via wmux.cmd (the 0.48.0 path) ===
    exit=0
    stray file named 0 created: True
      contents: { "result": 40 }
    
    === via wmux.ps1 (0.50.0) ===
    { "result": true }
    exit=0
    stray file named 0 created: False
    

    Same argument, same host, and the .cmd still exhibits it, which is the useful half of the control: the difference is the shim, not the environment.

    One thing this did not exercise: the startup probe and the PATH ordering. I invoked both shims by absolute path, because neither shim dir is on this machine's persisted PATH. Taking the 0.50.0 update re-offered the install location, I moved it from per-user to All Users, and a PATH entry I had added by hand still pointed at the old root. Filed as #158, which is where that belongs. (I edited this comment twice getting that sequence straight; #158 has the account I actually verified.)

    So read the above as "the shim does the right thing when it is reached", not as a verification of the gate.

    For what it is worth, the gate itself looks right on this machine when probed the way you describe: with an inherited Bypass cleared, powershell.exe reports CurrentUser = RemoteSigned< 6F82 /code> and pwsh.exe reports LocalMachine = RemoteSigned, the shipped .ps1 files carry no Zone.Identifier, and wmux-shim-probe.ps1 prints its marker in both hosts. Clearing the inherited policy mattered: probed without that, both hosts report Bypass and would have passed for the wrong reason, which is exactly the false positive you called out.

  3. added a commit that references this issue on Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0