win-admin — elevación puntual a PowerShell admin desde WSL
Script en ~/bin/win-admin (surface-ehas) que ejecuta un comando de PowerShell con permisos de administrador en el host Windows, invocado directamente desde una sesión de Claude Code corriendo en WSL. No requiere SSH ni ningún workaround de red hacia el host: WSL tiene interop nativo con binarios de Windows (powershell.exe, cmd.exe) y filesystem compartido (/mnt/c/...), así que el mismo bash puede invocar PowerShell directo.
Decisión de diseño: puntual, no un agente admin permanente
La alternativa considerada y descartada fue un “bridge” persistente: una Tarea Programada de Windows con Run with highest privileges + Run whether user is logged on or not (credenciales guardadas), disparada por Claude Code vía schtasks.exe /run cada vez que hiciera falta, sin ningún prompt. Esa alternativa da admin verdaderamente desatendido, pero es efectivamente crear un canal de escalación de privilegios permanente de Linux hacia admin de Windows: si algo en WSL se compromete (paquete malicioso, skill/MCP comprometido, prompt injection vía un archivo leído), ese algo hereda admin sobre todo el host.
win-admin en cambio no deja nada corriendo ni instalado entre usos — cada invocación es efímera: escribe archivos temporales, eleva, lee el resultado, y los borra. Sin credenciales guardadas, sin tarea programada, sin script permanente del lado Windows.
Uso
win-admin '<comando de PowerShell>'
# o por stdin:
echo '<comando de PowerShell>' | win-adminEjemplo real (agregar algo al inicio de Windows para todos los usuarios, que requiere HKLM):
win-admin 'Set-ItemProperty -Path "HKLM:\Software\Microsoft\Windows\CurrentVersion\Run" -Name "MiApp" -Value "C:\ruta\al\programa.exe"'Nota sobre UAC: en surface-wsl la elevación pasó en las pruebas sin mostrar el prompt visual de consent.exe (nivel de UAC bajo en esta cuenta admin) — no asumir que eso vale en otra cuenta/máquina; si UAC sí pregunta, hace falta aceptar el prompt en pantalla para que el comando complete.
Cómo funciona
#!/bin/bash
set -euo pipefail
if [ -n "${1:-}" ]; then
CMD="$1"
else
CMD="$(cat)"
fi
WIN_LOCALAPPDATA=$(powershell.exe -NoProfile -Command '$env:LOCALAPPDATA' | tr -d '\r')
WSL_TEMP=$(wslpath -u "$WIN_LOCALAPPDATA")/Temp
mkdir -p "$WSL_TEMP"
TS="$$_$(date +%s%N)"
ELEVATE_FILE="$WSL_TEMP/wsl_admin_elevate_$TS.ps1"
CMD_FILE="$WSL_TEMP/wsl_admin_cmd_$TS.ps1"
OUT_FILE="$WSL_TEMP/wsl_admin_out_$TS.txt"
cleanup() { rm -f "$ELEVATE_FILE" "$CMD_FILE" "$OUT_FILE"; }
trap cleanup EXIT
OUT_FILE_WIN=$(wslpath -w "$OUT_FILE")
CMD_FILE_WIN=$(wslpath -w "$CMD_FILE")
cat > "$ELEVATE_FILE" <<'PS1EOF'
param($ScriptPath)
Start-Process powershell -Verb RunAs -Wait -ArgumentList "-NoProfile","-ExecutionPolicy","Bypass","-File","$ScriptPath"
PS1EOF
cat > "$CMD_FILE" <<EOF
& {
try {
$CMD
} catch {
"ERROR: \$_"
}
} 2>&1 | Out-File -FilePath '$OUT_FILE_WIN' -Encoding utf8
EOF
ELEVATE_FILE_WIN=$(wslpath -w "$ELEVATE_FILE")
powershell.exe -NoProfile -File "$ELEVATE_FILE_WIN" -ScriptPath "$CMD_FILE_WIN" >/dev/null 2>&1 || true
if [ -s "$OUT_FILE" ]; then
sed '1s/^\xef\xbb\xbf//' "$OUT_FILE" # strip BOM que agrega Out-File
else
echo "(no output captured — UAC may have been declined, or the command produced nothing)" >&2
exit 1
fiFlujo: escribe el comando en un .ps1 temporal en %LOCALAPPDATA%\Temp, lo lanza con Start-Process -Verb RunAs -Wait desde un launcher (elevate.ps1) separado, y lee el resultado desde un archivo de salida que el script elevado escribe él mismo.
Gotchas encontrados armando esto
Tres problemas de PowerShell/quoting, no obvios, que costó aislar:
Start-Process -Verb RunAsno acepta-RedirectStandardOutput— tiraParameterBindingException: AmbiguousParameterSet. Por eso el script elevado tiene que escribir su propio archivo de salida (Out-File) en vez de que el proceso padre capture su stdout.- PowerShell no permite pipear directo la salida de un
try {} catch {}—try { ... } catch { ... } | Out-FiletiraEmptyPipeElement(“no se permiten elementos de canalización vacíos”) al correr como script (-File), aunque como one-liner interactivo a veces cuela. Fix: envolver el try/catch en un script block invocado,& { try {...} catch {...} } | Out-File, que sí es pipeable. - Quoting anidado bash →
powershell.exe -Command→Start-Process -ArgumentListes frágil y falla en silencio (exit 0, sin archivo de salida, sin error visible) en vez de tirar una excepción clara. Se resolvió sacando la llamada aStart-Process -Verb RunAsde un-Commandstring y metiéndola en su propio.ps1(elevate.ps1) invocado con-File, evitando que bash tenga que escapar comillas de PowerShell dentro de comillas de PowerShell. - Un proceso elevado que escribe en
C:\Windows\Tempdeja archivos que el usuario sin elevar no puede borrar (Permission denieddesde WSL). Se usa%LOCALAPPDATA%\Tempen su lugar — mismo usuario, mismo dueño de archivo, solo cambia el token de elevación del proceso que escribió.
Véase también
- Acceso SSH entre VMs — mecanismo de acceso administrativo análogo, pero entre Linux; este es el caso WSL↔Windows del mismo host