Skip to content

Commit 93691fe

Browse files
committed
feat: enable PowerShell execution for Cato on Windows
- Add shell:allow-execute, shell:allow-spawn, shell:allow-stdin-write to Tauri capabilities so the desktop app can run shell commands - Add Windows-aware command routing through powershell.exe/pwsh.exe in the shell tool (sandbox, gateway, and full modes) - Add Windows allowlist (dir, type, findstr, powershell, pwsh, cmd, etc.) - Fix shlex.split for Windows backslash paths in gateway mode - Add Windows env vars (SYSTEMROOT, COMSPEC, etc.) to minimal env - Handle .exe/.cmd/.bat suffixes in Tauri sidecar binary lookup https://claude.ai/code/session_01Fego7poo6HPnc8CDP8xXGz
1 parent ff8f163 commit 93691fe

3 files changed

Lines changed: 98 additions & 20 deletions

File tree

cato/tools/shell.py

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
import logging
1717
import os
1818
import shlex
19+
import shutil
1920
import tempfile
2021
from datetime import datetime, timezone
2122
from pathlib import Path
2223
from typing import Any, Optional
2324

24-
from ..platform import get_data_dir
25+
from ..platform import IS_WINDOWS, get_data_dir
2526

2627
logger = 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(

desktop/src-tauri/capabilities/default.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
"permissions": [
77
"core:default",
88
"shell:allow-open",
9+
"shell:allow-execute",
10+
"shell:allow-spawn",
11+
"shell:allow-stdin-write",
912
"notification:default",
1013
"notification:allow-is-permission-granted",
1114
"notification:allow-request-permission",

desktop/src-tauri/src/sidecar.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,25 @@ impl SidecarManager {
138138
/// Find the cato binary — try sidecar path first, then PATH.
139139
fn find_cato_binary() -> String {
140140
if let Ok(exe) = std::env::current_exe() {
141-
let sidecar = exe.parent().unwrap_or(exe.as_path()).join("cato");
142-
if sidecar.exists() {
143-
return sidecar.to_string_lossy().to_string();
141+
let dir = exe.parent().unwrap_or(exe.as_path());
142+
143+
// Try platform-appropriate names
144+
let candidates = if cfg!(windows) {
145+
vec!["cato.exe", "cato.cmd", "cato.bat", "cato"]
146+
} else {
147+
vec!["cato"]
148+
};
149+
150+
for name in candidates {
151+
let sidecar = dir.join(name);
152+
if sidecar.exists() {
153+
return sidecar.to_string_lossy().to_string();
154+
}
144155
}
145156
}
146157

147158
// Fallback: assume `cato` is on PATH (works during development)
148-
"cato".to_string()
159+
if cfg!(windows) { "cato.exe" } else { "cato" }.to_string()
149160
}
150161
}
151162

0 commit comments

Comments
 (0)