1616import logging
1717import os
1818import shlex
19+ import shutil
1920import tempfile
2021from datetime import datetime , timezone
2122from pathlib import Path
2223from typing import Any , Optional
2324
24- from ..platform import get_data_dir
25+ from ..platform import IS_WINDOWS , get_data_dir
2526
2627logger = logging .getLogger (__name__ )
2728
@@ -50,6 +51,13 @@ class ShellTool:
5051 "mkdir" , "cp" , "mv" , "chmod" , "pwd" , "env" , "which" , "date" ,
5152 ]
5253
54+ # Windows equivalents — automatically added on Windows
55+ WINDOWS_ALLOWLIST : list [str ] = [
56+ "dir" , "type" , "findstr" , "where" , "powershell" , "pwsh" ,
57+ "powershell.exe" , "pwsh.exe" , "cmd" , "cmd.exe" ,
58+ "Get-ChildItem" , "Get-Content" , "Set-Location" ,
59+ ]
60+
5361 # Extended allowlist — opt-in only, NOT in DEFAULT_ALLOWLIST
5462 # Add these to ~/.cato/exec-approvals.json if you need them
5563 EXTENDED_ALLOWLIST : list [str ] = ["curl" , "wget" , "pip" , "pip3" , "npm" , "node" ]
@@ -104,8 +112,13 @@ async def _run(
104112
105113 if mode == "gateway" :
106114 allowlist = self ._load_allowlist ()
107- first_word = shlex .split (command )[0 ] if command .strip () else ""
108- base_cmd = Path (first_word ).name # strip path prefix if any
115+ if IS_WINDOWS :
116+ # On Windows, shlex.split can choke on backslash paths;
117+ # use a simple whitespace split for the first token.
118+ first_word = command .strip ().split ()[0 ] if command .strip () else ""
119+ else :
120+ first_word = shlex .split (command )[0 ] if command .strip () else ""
121+ base_cmd = Path (first_word ).stem if IS_WINDOWS else Path (first_word ).name
109122 if base_cmd not in allowlist :
110123 self ._audit (mode , command , - 1 , blocked = True )
111124 raise PermissionError (
@@ -133,11 +146,19 @@ async def _run(
133146 # ------------------------------------------------------------------
134147
135148 async def _run_sandbox (self , command : str , timeout : int , cwd : Path ) -> dict :
136- """Run via subprocess with shlex-parsed args (no shell=True)."""
137- try :
138- cmd_args = shlex .split (command )
139- except ValueError as exc :
140- return {"stdout" : "" , "stderr" : f"shlex parse error: { exc } " , "returncode" : 1 , "truncated" : False }
149+ """Run via subprocess with shlex-parsed args (no shell=True).
150+
151+ On Windows, routes commands through PowerShell (pwsh.exe or
152+ powershell.exe) so that both native executables and PowerShell
153+ cmdlets work seamlessly.
154+ """
155+ if IS_WINDOWS :
156+ cmd_args = self ._build_windows_cmd (command )
157+ else :
158+ try :
159+ cmd_args = shlex .split (command )
160+ except ValueError as exc :
161+ return {"stdout" : "" , "stderr" : f"shlex parse error: { exc } " , "returncode" : 1 , "truncated" : False }
141162
142163 with tempfile .TemporaryDirectory () as tmp :
143164 run_dir = cwd if cwd .exists () else Path (tmp )
@@ -159,13 +180,26 @@ async def _run_sandbox(self, command: str, timeout: int, cwd: Path) -> dict:
159180 return self ._build_result (stdout_b , stderr_b , proc .returncode )
160181
161182 async def _run_full (self , command : str , timeout : int , cwd : Path ) -> dict :
162- """Run via asyncio.create_subprocess_shell — unrestricted."""
163- proc = await asyncio .create_subprocess_shell (
164- command ,
165- stdout = asyncio .subprocess .PIPE ,
166- stderr = asyncio .subprocess .PIPE ,
167- cwd = str (cwd ) if cwd .exists () else None ,
168- )
183+ """Run via asyncio.create_subprocess_shell — unrestricted.
184+
185+ On Windows this routes through PowerShell so cmdlets and
186+ native executables both work.
187+ """
188+ if IS_WINDOWS :
189+ cmd_args = self ._build_windows_cmd (command )
190+ proc = await asyncio .create_subprocess_exec (
191+ * cmd_args ,
192+ stdout = asyncio .subprocess .PIPE ,
193+ stderr = asyncio .subprocess .PIPE ,
194+ cwd = str (cwd ) if cwd .exists () else None ,
195+ )
196+ else :
197+ proc = await asyncio .create_subprocess_shell (
198+ command ,
199+ stdout = asyncio .subprocess .PIPE ,
200+ stderr = asyncio .subprocess .PIPE ,
201+ cwd = str (cwd ) if cwd .exists () else None ,
202+ )
169203 try :
170204 stdout_b , stderr_b = await asyncio .wait_for (
171205 proc .communicate (), timeout = timeout
@@ -206,12 +240,42 @@ def _load_allowlist(self) -> set[str]:
206240 return set (data )
207241 except (json .JSONDecodeError , OSError ):
208242 pass
209- return set (self .DEFAULT_ALLOWLIST )
243+ base = set (self .DEFAULT_ALLOWLIST )
244+ if IS_WINDOWS :
245+ base .update (self .WINDOWS_ALLOWLIST )
246+ return base
247+
248+ @classmethod
249+ def _find_powershell (cls ) -> str :
250+ """Locate the PowerShell executable (pwsh preferred, falls back to powershell.exe)."""
251+ for candidate in ("pwsh" , "powershell" ):
252+ found = shutil .which (candidate )
253+ if found :
254+ return found
255+ # Absolute fallback — should always exist on modern Windows
256+ return r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
257+
258+ @classmethod
259+ def _build_windows_cmd (cls , command : str ) -> list [str ]:
260+ """Wrap *command* for execution via PowerShell on Windows.
261+
262+ Uses ``-NoProfile -NonInteractive -Command`` so that the user's
263+ profile scripts don't interfere and the process exits cleanly.
264+ """
265+ ps = cls ._find_powershell ()
266+ return [ps , "-NoProfile" , "-NonInteractive" , "-Command" , command ]
210267
211268 @staticmethod
212269 def _minimal_env () -> dict [str , str ]:
213270 """Return a trimmed environment for sandbox execution."""
214271 keep = {"PATH" , "HOME" , "USER" , "LANG" , "TERM" , "TMPDIR" , "TMP" , "TEMP" }
272+ if IS_WINDOWS :
273+ # Windows needs extra env vars for PowerShell / .NET to function
274+ keep .update ({
275+ "SYSTEMROOT" , "COMSPEC" , "APPDATA" , "LOCALAPPDATA" ,
276+ "USERPROFILE" , "PROGRAMFILES" , "PROGRAMFILES(X86)" ,
277+ "COMMONPROGRAMFILES" , "WINDIR" , "PSModulePath" ,
278+ })
215279 return {k : v for k , v in os .environ .items () if k in keep }
216280
217281 def _audit (
0 commit comments