From 73ffb99e7920dacfc1166c1554b91dfdad4898a5 Mon Sep 17 00:00:00 2001 From: Frederick Daell Lied Diaz Date: Sun, 2 Aug 2026 14:48:08 -0400 Subject: [PATCH 1/5] feat: use PowerShell 7 for Windows commands --- .github/workflows/compliance.yml | 4 +- README.md | 14 +- coding_tools_mcp/processes.py | 154 ++++++++++++++++++++++ coding_tools_mcp/server.py | 16 ++- docs/limitations.md | 5 + docs/runtime-contract-v0.2.md | 2 +- tests/test_windows_pwsh.py | 211 +++++++++++++++++++++++++++++++ uv.lock | 59 ++++++++- 8 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 tests/test_windows_pwsh.py diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index faeb9a2..6445449 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -123,4 +123,6 @@ jobs: run: | call "%VCVARSALL%" x64 where cl.exe - python -m unittest tests.compliance.test_windows_msvc_smoke + python -m unittest ^ + tests.compliance.test_windows_msvc_smoke ^ + tests.test_windows_pwsh diff --git a/README.md b/README.md index a178309..9a35062 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ compatibility). A one-line installer, per-client walkthroughs, and troubleshooting live in [docs/quickstart.md](docs/quickstart.md) and [docs/mcp-client-config.md](docs/mcp-client-config.md). +On Windows, string commands run through PowerShell 7 (`pwsh`) with +`-NoLogo -NoProfile -NonInteractive`. Set +`CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path to pin +the launcher. The server reports `SHELL_NOT_FOUND` or +`SHELL_VERSION_UNSUPPORTED` instead of falling back to `cmd.exe`. + ## Seven things to try **1. Make Claude Desktop your coding agent.** The config above is all it @@ -116,9 +122,11 @@ Per-workspace profiles, server and tunnel start/stop, credential setup with clipboard helpers, live health checks. English and 简体中文. **6. Keep an interactive command alive.** `exec_command` starts a REPL or -debugger under a real PTY; `write_stdin` feeds it across turns; `read_output` -pages long output; `kill_command` cleans up. Long-running processes are -first-class, with deadline watchdogs and bounded buffers. +debugger under a real POSIX PTY; `write_stdin` feeds it across turns; +`read_output` pages long output; `kill_command` cleans up. Long-running +processes are first-class, with deadline watchdogs and bounded buffers. +Windows uses PowerShell 7 for non-TTY commands; ConPTY remains a separate +limitation. **7. Give your own agent production-grade hands.** Building an agent loop with the Anthropic SDK or anything else? Don't hand-roll file and exec tools — diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index 382f019..21497e6 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -1,10 +1,13 @@ from __future__ import annotations +import functools +import ntpath import os import signal import subprocess import threading import time +from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any, BinaryIO @@ -14,6 +17,154 @@ COMMAND_BUFFER_BYTES = 524_288 HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) +PWSH_PATH_ENV = "CODING_TOOLS_MCP_PWSH_PATH" + + +def _environment_value(env: Mapping[str, str], target: str) -> str | None: + target_upper = target.upper() + for name, value in env.items(): + if name.upper() == target_upper: + return value + return None + + +def _pwsh_probe_environment() -> dict[str, str]: + """Build a minimal host-derived environment without server secrets.""" + + allowed = ("PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "COMSPEC") + result: dict[str, str] = {} + for name in allowed: + value = _environment_value(os.environ, name) + if value is not None: + result[name] = value + return result + + +def _find_windows_executable_on_path(filename: str, path: str | None) -> str | None: + """Search absolute PATH entries without Windows' implicit current-directory lookup.""" + + if not path: + return None + current_dir = ntpath.normcase(ntpath.abspath(os.getcwd())) + for raw_entry in path.split(";"): + entry = ntpath.expandvars(raw_entry.strip().strip('"')) + if not entry or not ntpath.isabs(entry): + continue + normalized_entry = ntpath.normcase(ntpath.abspath(entry)) + try: + if ntpath.commonpath((current_dir, normalized_entry)) == current_dir: + continue + except ValueError: + pass + candidate = ntpath.normpath(ntpath.join(entry, filename)) + if os.path.isfile(candidate): + return candidate + return None + + +def resolve_pwsh() -> str: + """Resolve PowerShell 7 only from the trusted server process environment.""" + + executable: str | None + configured = (_environment_value(os.environ, PWSH_PATH_ENV) or "").strip().strip('"') + if configured: + executable = ntpath.normpath(ntpath.expandvars(configured)) + if ( + not ntpath.isabs(executable) + or ntpath.basename(executable).lower() != "pwsh.exe" + or not os.path.isfile(executable) + ): + raise ToolFailure( + "SHELL_NOT_FOUND", + f"{PWSH_PATH_ENV} must point to an existing absolute pwsh.exe path.", + category="runtime", + details={"executable": executable, "environment_variable": PWSH_PATH_ENV}, + ) + else: + executable = _find_windows_executable_on_path( + "pwsh.exe", + _environment_value(os.environ, "PATH"), + ) + if executable is None: + raise ToolFailure( + "SHELL_NOT_FOUND", + "PowerShell 7 is required for Windows string commands, but pwsh was not found on PATH.", + category="runtime", + details={ + "executable": "pwsh", + "retry_hint": ( + "Install PowerShell 7 and add pwsh to PATH, or set " + f"{PWSH_PATH_ENV} to its absolute path." + ), + }, + ) + major = pwsh_major_version(executable) + if major < 7: + raise ToolFailure( + "SHELL_VERSION_UNSUPPORTED", + f"PowerShell 7 or newer is required; resolved major version {major}.", + category="runtime", + details={"executable": executable, "major_version": major, "required_major_version": 7}, + ) + return executable + + +@functools.lru_cache(maxsize=16) +def pwsh_major_version(executable: str) -> int: + """Return and cache the major version reported by a pwsh executable.""" + + try: + completed = subprocess.run( + [ + executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "$PSVersionTable.PSVersion.Major", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=5, + check=False, + env=_pwsh_probe_environment(), + cwd=ntpath.dirname(executable) or None, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ToolFailure( + "SHELL_VERSION_UNSUPPORTED", + "PowerShell version could not be verified.", + category="runtime", + details={"executable": executable, "reason": str(exc)}, + ) from exc + stdout = completed.stdout.strip() + if completed.returncode != 0 or not stdout.isdigit(): + raise ToolFailure( + "SHELL_VERSION_UNSUPPORTED", + "PowerShell version could not be verified.", + category="runtime", + details={ + "executable": executable, + "exit_code": completed.returncode, + "stderr": completed.stderr.strip()[:500], + }, + ) + return int(stdout) + + +def build_pwsh_argv(executable: str, command: str) -> list[str]: + """Build a deterministic non-interactive PowerShell invocation.""" + + return [ + executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command, + ] def terminate_process_group( @@ -68,6 +219,9 @@ def spawn_process( """Spawn a pipe-backed or true POSIX PTY-backed process.""" if not tty: + if os.name == "nt" and shell and isinstance(command, str): + command = build_pwsh_argv(resolve_pwsh(), command) + shell = False process = subprocess.Popen( command, cwd=cwd, diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index dfe3c71..c021f75 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -178,11 +178,22 @@ class ModeCapabilities: r"(https?://|urllib\.request|urllib3|requests\.|http\.client|\bHTTPConnection\b|\bHTTPSConnection\b|socket\.|aiohttp|httpx|\bcurl\b|\bwget\b|\bnc\b|\bnetcat\b|\bssh\b|\bscp\b|\bftp\b)", re.I, ) +POWERSHELL_NETWORK_RE = re.compile( + r"(?:^|[;&|{}\r\n])\s*(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" + r"Test-NetConnection|Test-Connection|Resolve-DnsName|iwr|irm|tnc|ping(?:\.exe)?|" + r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|(?:System\.)?Net\.", + re.I, +) SHELL_EXPANSION_RE = re.compile(r"(`|\$\(|\$\{)") DESTRUCTIVE_RE = re.compile( r"(^|\s)(sudo|su|chmod\s+-R|chown\s+-R|mkfs|mount|umount|find\b[^;&|]*\s-delete\b|git\b[^;&|]*\breset\s+--hard\b|git\b[^;&|]*\bclean\s+-[^\s]*[fx][^\s]*|rm\s+-[^\s]*r[^\s]*f|rm\s+-[^\s]*f[^\s]*r)\b", re.I, ) +POWERSHELL_DESTRUCTIVE_RE = re.compile( + r"(?:^|[;&|{}\r\n])\s*(?:Remove-Item|rm|ri|del|erase|rmdir|rd)\b" + r"(?=[^;&|{}\r\n]*\s-(?:r|re|rec|recu|recur|recurs|recurse)\b)", + re.I, +) MAX_HTTP_REQUEST_BYTES = 1_048_576 EXEC_PREVIEW_BYTES = 4096 MAX_ACTIVE_COMMANDS = 16 @@ -2470,14 +2481,15 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": "destructive_command", "command": compact}, ) - if DESTRUCTIVE_RE.search(cmd): + if DESTRUCTIVE_RE.search(cmd) or POWERSHELL_DESTRUCTIVE_RE.search(cmd): raise ToolFailure( "PERMISSION_REQUIRED", "Destructive commands are blocked without explicit permission.", category="permission", details={"permission": "destructive_command", "command": compact}, ) - if not self.allow_network and NETWORK_RE.search(cmd) and not is_literal_network_reference_command(cmd): + network_command = NETWORK_RE.search(cmd) or POWERSHELL_NETWORK_RE.search(cmd) + if not self.allow_network and network_command and not is_literal_network_reference_command(cmd): raise ToolFailure( "PERMISSION_REQUIRED", "Network access is denied by default.", diff --git a/docs/limitations.md b/docs/limitations.md index 3948697..c391d22 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -6,6 +6,11 @@ - Non-Linux platforms or Linux kernels without Landlock are not production targets for `exec_command` without an external sandbox. - This build uses real POSIX PTYs but does not implement Windows ConPTY; `tty=true` returns `TTY_UNSUPPORTED` on Windows. +- Windows string commands require PowerShell 7 (`pwsh`) and run with + `-NoLogo -NoProfile -NonInteractive`. Operators may pin an absolute trusted + executable with `CODING_TOOLS_MCP_PWSH_PATH`; otherwise the server searches + absolute entries on its own process `PATH` while excluding the current + directory tree. There is no `cmd.exe` fallback. - Portable filesystems do not provide a transaction across unrelated directories. `apply_patch` keeps same-directory backups and rolls back the full staged set, but a storage failure that also prevents rollback is surfaced diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index fabbf9b..8f4b60b 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -112,7 +112,7 @@ Tool failures keep the same envelope with `isError: true`, a readable error in Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SHELL_NOT_FOUND", "SHELL_VERSION_UNSUPPORTED", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py new file mode 100644 index 0000000..1cc33ae --- /dev/null +++ b/tests/test_windows_pwsh.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from coding_tools_mcp import processes +from coding_tools_mcp.errors import ToolFailure +from coding_tools_mcp.server import Runtime, ShellEnvPolicy + + +class WindowsPowerShellSpawnTests(unittest.TestCase): + def tearDown(self) -> None: + processes.pwsh_major_version.cache_clear() + + def test_windows_string_command_uses_server_resolved_noninteractive_pwsh(self) -> None: + captured: dict[str, object] = {} + + class FakeProcess: + pass + + def fake_popen(command: object, **kwargs: object) -> FakeProcess: + captured["command"] = command + captured.update(kwargs) + return FakeProcess() + + trusted = r"C:\Program Files\PowerShell\7\pwsh.exe" + trusted_path = r"C:\Program Files\PowerShell\7;C:\Windows\System32" + attacker_path = r"C:\workspace\bin" + + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": trusted_path}, clear=True), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == trusted), + patch.object(processes, "pwsh_major_version", return_value=7), + patch.object(processes.subprocess, "Popen", side_effect=fake_popen), + ): + process, pty_fd = processes.spawn_process( + "Write-Output 'ok'", + cwd=r"C:\workspace", + shell=True, + env={"Path": attacker_path}, + tty=False, + popen_kwargs={}, + ) + + self.assertIsInstance(process, FakeProcess) + self.assertIsNone(pty_fd) + self.assertEqual( + captured["command"], + [ + trusted, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + "Write-Output 'ok'", + ], + ) + self.assertIs(captured["shell"], False) + self.assertEqual(captured["env"], {"Path": attacker_path}) + + def test_windows_pwsh_resolution_skips_current_directory_entries(self) -> None: + trusted = r"C:\Program Files\PowerShell\7\pwsh.exe" + malicious = r"C:\workspace\pwsh.exe" + + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\workspace"), + patch.dict( + processes.os.environ, + {"Path": r".;C:\workspace;C:\Program Files\PowerShell\7"}, + clear=True, + ), + patch.object( + processes.os.path, + "isfile", + side_effect=lambda path: path in {trusted, malicious}, + ), + patch.object(processes, "pwsh_major_version", return_value=7), + ): + self.assertEqual(processes.resolve_pwsh(), trusted) + + def test_windows_pwsh_rejects_invalid_explicit_path(self) -> None: + with ( + patch.object(processes.os, "name", "nt"), + patch.dict( + processes.os.environ, + {processes.PWSH_PATH_ENV: r".\pwsh.exe"}, + clear=True, + ), + patch.object(processes.os.path, "isfile", return_value=True), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_pwsh() + + self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + + def test_windows_string_command_requires_pwsh(self) -> None: + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": r"C:\Windows\System32"}, clear=True), + patch.object(processes.os.path, "isfile", return_value=False), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_pwsh() + + self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + + def test_windows_string_command_rejects_powershell_older_than_7(self) -> None: + executable = r"C:\PowerShell\6\pwsh.exe" + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": r"C:\PowerShell\6"}, clear=True), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == executable), + patch.object(processes, "pwsh_major_version", return_value=6), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_pwsh() + + self.assertEqual(raised.exception.code, "SHELL_VERSION_UNSUPPORTED") + + @unittest.skipUnless(os.name == "nt", "requires Windows PowerShell 7") + def test_runtime_executes_bare_powershell_syntax(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime( + Path(tmp), + permission_mode="trusted", + shell_env_policy=ShellEnvPolicy(inherit="all"), + ) + try: + result = runtime.exec_command( + { + "cmd": "Write-Output ('PS_MAJOR=' + $PSVersionTable.PSVersion.Major)", + "timeout_ms": 30_000, + "yield_time_ms": 30_000, + "verbosity": "full", + } + ) + finally: + runtime.close() + + self.assertEqual(result.get("status"), "exited", result) + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("PS_MAJOR=7", str(result.get("stdout", ""))) + + +class WindowsPowerShellPolicyTests(unittest.TestCase): + def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) -> None: + commands = ( + "Invoke-WebRequest -Uri example.com", + "Invoke-RestMethod -Uri api.example.com", + "iwr example.com", + "irm api.example.com", + "Start-BitsTransfer -Source example.com -Destination out.bin", + "New-Object System.Net.WebClient", + "Test-NetConnection example.com", + "Test-Connection example.com", + "ping example.com", + "tnc example.com", + "Resolve-DnsName example.com", + "[System.Net.Dns]::GetHostAddresses('example.com')", + "Write-Output ok\nInvoke-WebRequest -Uri example.com", + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="safe") + try: + for command in commands: + with self.subTest(command=command): + with self.assertRaises(ToolFailure) as raised: + runtime._check_command_policy(command, {}) + self.assertEqual(raised.exception.details.get("permission"), "network") + finally: + runtime.close() + + def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviations(self) -> None: + commands = ( + "Remove-Item -Recurse .", + "Remove-Item -Recurse -Force .", + "Remove-Item -Force -LiteralPath . -Recurse", + "Remove-Item -Recu -For .", + "rm -r .", + "rm -Recurse -Force .", + "ri -Recu .", + "ri -Force -Recurse .", + "del -Recurse .", + "rmdir -r .", + "Write-Output ok\r\nRemove-Item -Recurse .", + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="safe") + try: + for command in commands: + with self.subTest(command=command): + with self.assertRaises(ToolFailure) as raised: + runtime._check_command_policy(command, {}) + self.assertEqual( + raised.exception.details.get("permission"), + "destructive_command", + ) + finally: + runtime.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index a6bffa2..bb03c28 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "coding-tools-mcp" -version = "0.2.0" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "pyjwt" }, @@ -61,6 +61,7 @@ desktop = [ ] dev = [ { name = "mypy" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "typing-extensions" }, ] @@ -75,6 +76,7 @@ requires-dist = [ { name = "psutil", marker = "extra == 'desktop'", specifier = ">=7.0,<8" }, { name = "pyjwt", specifier = ">=2.8" }, { name = "pyside6", marker = "extra == 'desktop'", specifier = ">=6.8,<6.9" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12" }, ] @@ -391,6 +393,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/5d8c6da7586e57ee032643e0c0e62335ef1a1add1a980160ddd1654f1d8d/PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07", size = 72191029, upload-time = "2025-03-27T12:15:10.425Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.17" From 784404c4492461d2ed37be8b038c328684dac8e2 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:46:45 +0000 Subject: [PATCH 2/5] Gate PowerShell dynamic syntax instead of scanning for cmdlet names PowerShell resolves commands at runtime, so the new cmdlet-name scans could not see what a command would actually run. Both of these passed every scan while downloading a file and deleting a tree recursively: $c='Invoke-WebRequest'; & $c example.com $p=@{Recurse=$true}; Remove-Item . @p Enumerating more cmdlet names cannot fix this, because variables, splatting, redefined aliases, .NET member access, and base64 script payloads all express the same commands without naming them. Safe mode now requires the existing shell_expansion permission for PowerShell dynamic syntax and the inline_script permission for nested shells, which restores the precondition the destructive and network scans depend on: the command text is literal. The gate only applies where PowerShell is the interpreter, so POSIX expansion policy is unchanged, and it runs after the existing scans so recognized commands keep their precise permission label. Literal PowerShell keeps working in safe mode; dynamic syntax needs request_permissions or trusted mode, which docs now state. Also anchors the Net. network pattern on a word boundary so ordinary words ending in "net." no longer register as network access. Co-authored-by: Cursor --- README.md | 6 +++ coding_tools_mcp/server.py | 66 +++++++++++++++++++++++++++++- docs/limitations.md | 9 ++++ tests/test_windows_pwsh.py | 84 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a35062..f634d63 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ On Windows, string commands run through PowerShell 7 (`pwsh`) with the launcher. The server reports `SHELL_NOT_FOUND` or `SHELL_VERSION_UNSUPPORTED` instead of falling back to `cmd.exe`. +Because PowerShell resolves commands at runtime, `safe` mode on Windows gates +dynamic syntax — variables, splatting, call and dot-source operators, `::` +member access, alias/expression evaluation, and nested shells — behind the +`shell_expansion` and `inline_script` permissions. Literal commands are +unaffected; use `request_permissions` or `trusted` mode for the rest. + ## Seven things to try **1. Make Claude Desktop your coding agent.** The config above is all it diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index aab0242..e774c21 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -182,7 +182,7 @@ class ModeCapabilities: POWERSHELL_NETWORK_RE = re.compile( r"(?:^|[;&|{}\r\n])\s*(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" r"Test-NetConnection|Test-Connection|Resolve-DnsName|iwr|irm|tnc|ping(?:\.exe)?|" - r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|(?:System\.)?Net\.", + r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|\b(?:System\.)?Net\.", re.I, ) SHELL_EXPANSION_RE = re.compile(r"(`|\$\(|\$\{)") @@ -195,6 +195,20 @@ class ModeCapabilities: r"(?=[^;&|{}\r\n]*\s-(?:r|re|rec|recu|recur|recurs|recurse)\b)", re.I, ) +# PowerShell resolves commands at runtime, so scanning for cmdlet names cannot +# see through a variable, a splatted parameter set, a redefined alias, or a +# .NET member call. Any of those constructs makes the destructive and network +# scans above unsound, so they require the same explicit permission that POSIX +# command substitution already requires instead of being scanned for keywords. +POWERSHELL_DYNAMIC_RE = re.compile( + r"(?P\$)" + r"|(?P(?:^|[\s;&|(){},=])@)" + r"|(?P::)" + r"|(?P(?:^|[;|(){}\r\n]|&&|\|\|)\s*(?:&(?!&)|\.)\s)" + r"|(?P\b(?:Invoke-Expression|iex|Invoke-Command|icm|New-Object|" + r"Add-Type|Set-Alias|New-Alias|sal|nal)\b)", + re.I, +) MAX_HTTP_REQUEST_BYTES = 1_048_576 EXEC_PREVIEW_BYTES = 4096 MAX_ACTIVE_COMMANDS = 16 @@ -2531,6 +2545,24 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": "network", "command": compact}, ) + # Runs last so a command the scans above already recognized keeps its + # precise permission label; this is the catch-all for the PowerShell + # syntax that makes those scans unsound in the first place. + if not self.capabilities.shell_expansion and powershell_executes_string_commands(): + construct = powershell_dynamic_construct(cmd) + if construct is not None: + raise ToolFailure( + "PERMISSION_REQUIRED", + "PowerShell dynamic syntax requires explicit permission because the command " + "a variable, splat, alias, or .NET member resolves to cannot be verified " + "statically.", + category="permission", + details={ + "permission": "shell_expansion", + "construct": construct, + "command": compact, + }, + ) def _add_exec_diagnostics(self, payload: dict[str, Any]) -> None: diagnostics = exec_output_diagnostics(payload) @@ -3764,6 +3796,20 @@ def inline_script_segment(command: str | None, args: list[str]) -> dict[str, str return {"command": name, "option": option} if name in {"ruby", "perl"} and "-e" in args: return {"command": name, "option": "-e"} + if name in {"pwsh", "pwsh.exe", "powershell", "powershell.exe"}: + for arg in args: + if not arg.startswith("-"): + continue + option = arg.lstrip("-").lower() + # PowerShell accepts any unambiguous prefix, so -e, -enc, and + # -EncodedCommand all smuggle a base64 script past text scanning. + if option and ("command".startswith(option) or "encodedcommand".startswith(option)): + return {"command": name, "option": arg} + return None + if name in {"cmd", "cmd.exe"}: + for arg in args: + if arg.lstrip("-/").lower() in {"c", "k"}: + return {"command": name, "option": arg} return None @@ -3918,6 +3964,24 @@ def is_inspectable_path_argument(token: str) -> bool: return "." in PurePosixPath(normalized).name +def powershell_executes_string_commands() -> bool: + """True when this host runs string commands through PowerShell 7. + + Mirrors the spawn decision in processes.spawn_process, which wraps Windows + string commands in pwsh. Command policy has to agree with that decision: + PowerShell syntax is only worth gating where PowerShell is the interpreter. + """ + + return os.name == "nt" + + +def powershell_dynamic_construct(command: str) -> str | None: + match = POWERSHELL_DYNAMIC_RE.search(command) + if match is None: + return None + return match.lastgroup + + def is_literal_network_reference_command(command: str) -> bool: try: tokens = shlex_split(command) diff --git a/docs/limitations.md b/docs/limitations.md index c391d22..58b420e 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -11,6 +11,15 @@ executable with `CODING_TOOLS_MCP_PWSH_PATH`; otherwise the server searches absolute entries on its own process `PATH` while excluding the current directory tree. There is no `cmd.exe` fallback. +- Windows `safe` mode cannot statically decide what PowerShell dynamic syntax + resolves to, so variables (`$`), splatting (`@`), the call and dot-source + operators, .NET member access (`::`), and alias or expression evaluation + cmdlets require the `shell_expansion` permission even when the command would + turn out to be harmless. Nested shells (`pwsh -Command`, `pwsh + -EncodedCommand`, `cmd /c`) require `inline_script`. Use + `request_permissions` or `trusted` mode for commands that need them. Command + scanning is not a sandbox: this build has no OS-level confinement on Windows, + so `safe` mode there is a best-effort gate rather than a boundary. - Portable filesystems do not provide a transaction across unrelated directories. `apply_patch` keeps same-directory backups and rolls back the full staged set, but a storage failure that also prevents rollback is surfaced diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py index 1cc33ae..732e96a 100644 --- a/tests/test_windows_pwsh.py +++ b/tests/test_windows_pwsh.py @@ -7,6 +7,7 @@ from unittest.mock import patch from coding_tools_mcp import processes +from coding_tools_mcp import server from coding_tools_mcp.errors import ToolFailure from coding_tools_mcp.server import Runtime, ShellEnvPolicy @@ -207,5 +208,88 @@ def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviation runtime.close() +class WindowsPowerShellDynamicSyntaxTests(unittest.TestCase): + """PowerShell resolves commands at runtime, so cmdlet-name scanning alone + cannot decide whether a command is destructive or reaches the network.""" + + def assert_policy(self, command: str, *, mode: str = "safe") -> ToolFailure | None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode=mode) + try: + with patch.object(server, "powershell_executes_string_commands", return_value=True): + try: + runtime._check_command_policy(command, {}) + except ToolFailure as failure: + return failure + finally: + runtime.close() + return None + + def test_safe_mode_gates_command_names_built_from_variables_and_splatting(self) -> None: + # Both of these pass every cmdlet-name scan while still invoking a + # network download and a recursive delete. + cases = { + "$c='Invoke-WebRequest'; & $c example.com": "expansion", + "$p=@{Recurse=$true}; Remove-Item . @p": "expansion", + "Set-Alias grab Invoke-WebRequest; grab example.com": "dynamic_eval", + "[IO.File]::Delete('C:\\data\\report.csv')": "static_member", + "Remove-Item . @args": "splatting", + ". .\\payload.ps1": "call_operator", + "iex (Get-Content payload.txt -Raw)": "dynamic_eval", + } + for command, construct in cases.items(): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure, f"{command!r} was allowed in safe mode") + assert failure is not None + self.assertEqual(failure.details.get("permission"), "shell_expansion") + self.assertEqual(failure.details.get("construct"), construct) + + def test_safe_mode_gates_nested_shells_that_smuggle_encoded_scripts(self) -> None: + for command in ( + "pwsh -EncodedCommand SQBuAHYAbwBrAGUA", + "pwsh -enc SQBuAHYAbwBrAGUA", + "pwsh -Command Invoke-WebRequest example.com", + "powershell.exe -c whoami", + "cmd /c del /s /q C:\\data", + ): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure, f"{command!r} was allowed in safe mode") + assert failure is not None + self.assertEqual(failure.details.get("permission"), "inline_script") + + def test_safe_mode_still_allows_literal_powershell_commands(self) -> None: + for command in ( + "Get-ChildItem -Path src -Recurse -Name", + "Write-Output ok", + "git commit -m 'fix parser' && git log --oneline -1", + "git config user.email dev@example.com", + ".\\build.exe --release", + "Get-Content README.md | Select-String planet.txt", + ): + with self.subTest(command=command): + self.assertIsNone(self.assert_policy(command), f"{command!r} was blocked in safe mode") + + def test_trusted_mode_allows_dynamic_syntax(self) -> None: + for command in ("$c='Get-Date'; & $c", "Remove-Item . @p"): + with self.subTest(command=command): + self.assertIsNone(self.assert_policy(command, mode="trusted")) + + def test_network_scan_does_not_flag_words_ending_in_net(self) -> None: + self.assertIsNone(server.POWERSHELL_NETWORK_RE.search("Get-Content planet.txt")) + self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("New-Object System.Net.WebClient")) + self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("[Net.Dns]::GetHostAddresses('a')")) + + def test_posix_hosts_keep_their_existing_expansion_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode="safe") + try: + with patch.object(server, "powershell_executes_string_commands", return_value=False): + runtime._check_command_policy("echo $HOME", {}) + finally: + runtime.close() + + if __name__ == "__main__": unittest.main() From b8d8bb38ebdaaa639ff41179ef21e6e61830b4f1 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:44:26 +0000 Subject: [PATCH 3/5] Fall back to trusted cmd.exe when PowerShell 7 is unavailable Windows string commands keep preferring a server-resolved PowerShell 7, but a missing or unverifiable unpinned pwsh no longer strands the agent: the server selects a trusted cmd.exe (COMSPEC or the Windows system directory), discloses the selection in server_info, check_exec_environment, and every exec_command result, and warns so the agent switches to cmd syntax. An explicit CODING_TOOLS_MCP_PWSH_PATH pin still fails instead of falling back. Hardening that review of the fallback surfaced: - Build the cmd.exe invocation as a raw /S /C command line. An argv list would be re-quoted with MS CRT rules, corrupting quoted arguments. - Slash-normalize Windows commands before the token-based policy scans: POSIX shlex eats backslashes, which let unquoted paths such as C:\Windows\...\powershell.exe -EncodedCommand slip past the nested interpreter and path checks. - Accept module-qualified spellings (Microsoft.PowerShell.Management\ Remove-Item) and path spellings (C:\...\format.com) in the PowerShell and cmd destructive/network scans. - Pin the shell selection per process behind a lock so concurrent execs agree on one interpreter and failed probes stop repeating; check_exec_environment re-resolves the pin on demand. - Stop applying the PowerShell scans to POSIX hosts, which had silently gated rm -r and ping there; POSIX policy is unchanged again. - Reduce safe-mode false positives: single-quoted PowerShell literals ('$5'), unpaired cmd percents (git log --format=%h), and CALL/FOR outside command position no longer require permissions. - Cover the real pwsh-hidden fallback chain end to end on the Windows runner, including quoted-argument fidelity through cmd.exe. Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 9 + README.md | 20 +- README.zh-CN.md | 7 + coding_tools_mcp/processes.py | 144 ++++++- coding_tools_mcp/server.py | 177 +++++++-- docs/ci-and-tests.md | 2 + docs/limitations.md | 12 +- docs/runtime-contract-v0.2.md | 2 +- docs/runtime-contract-v0.3.md | 15 +- docs/troubleshooting.md | 20 + tests/compliance/test_windows_msvc_smoke.py | 5 +- tests/test_windows_pwsh.py | 399 +++++++++++++++++++- uv.lock | 59 +-- 13 files changed, 759 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8cb1ca..9baa2f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +### Changed + +- Windows string commands now prefer a trusted PowerShell 7 launcher and + automatically retain `cmd.exe` compatibility when `pwsh` is unavailable. + The selected shell is visible to agents, and safe mode conservatively gates + dynamic syntax for both interpreters. + ## 0.3.0 - 2026-08-13 ### Changed diff --git a/README.md b/README.md index 3fa6a12..4f4a7fe 100644 --- a/README.md +++ b/README.md @@ -74,17 +74,21 @@ walkthroughs, and troubleshooting live in [docs/quickstart.md](docs/quickstart.md) and [docs/mcp-client-config.md](docs/mcp-client-config.md). -On Windows, string commands run through PowerShell 7 (`pwsh`) with -`-NoLogo -NoProfile -NonInteractive`. Set -`CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path to pin -the launcher. The server reports `SHELL_NOT_FOUND` or -`SHELL_VERSION_UNSUPPORTED` instead of falling back to `cmd.exe`. +On Windows, string commands prefer PowerShell 7 (`pwsh`) with +`-NoLogo -NoProfile -NonInteractive`. If `pwsh` is unavailable, the server +automatically preserves command execution through a trusted `cmd.exe` +compatibility fallback. `server_info`, `check_exec_environment`, and each +`exec_command` result disclose the selected shell so agents can use the right +syntax. Set `CODING_TOOLS_MCP_PWSH_PATH` to an absolute trusted `pwsh.exe` path +to pin PowerShell; an invalid explicit pin is reported instead of ignored. Because PowerShell resolves commands at runtime, `safe` mode on Windows gates dynamic syntax — variables, splatting, call and dot-source operators, `::` member access, alias/expression evaluation, and nested shells — behind the `shell_expansion` and `inline_script` permissions. Literal commands are -unaffected; use `request_permissions` or `trusted` mode for the rest. +unaffected; use `request_permissions` or `trusted` mode for the rest. Under the +`cmd.exe` fallback, percent expansion, caret escaping, and `CALL`/`FOR` +evaluation likewise require `shell_expansion`. ## Seven things to try @@ -134,8 +138,8 @@ clipboard helpers, live health checks. English and 简体中文. debugger under a real POSIX PTY; `write_stdin` feeds it across turns; `read_output` pages long output; `kill_command` cleans up. Long-running processes are first-class, with deadline watchdogs and bounded buffers. -Windows uses PowerShell 7 for non-TTY commands; ConPTY remains a separate -limitation. +Windows prefers PowerShell 7 and otherwise uses the disclosed `cmd.exe` +fallback for non-TTY commands; ConPTY remains a separate limitation. **7. Give your own agent production-grade hands.** Building an agent loop with the Anthropic SDK or anything else? Don't hand-roll file and exec tools — diff --git a/README.zh-CN.md b/README.zh-CN.md index 6d39621..f5b948b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,6 +67,13 @@ npx coding-tools-mcp --stdio --workspace /path/to/repo # Node 工具链 [docs/quickstart.md](docs/quickstart.md) 与 [docs/mcp-client-config.md](docs/mcp-client-config.md)。 +在 Windows 上,字符串命令优先使用 PowerShell 7(`pwsh`);未安装时会自动回退到 +可信系统路径中的 `cmd.exe`,不会让 agent 因缺少额外 shell 而失去命令执行能力。 +`server_info`、`check_exec_environment` 和每次 `exec_command` 都会说明实际使用的 +shell,便于 agent 选择正确语法。运维方也可以通过 +`CODING_TOOLS_MCP_PWSH_PATH` 固定一个绝对的 `pwsh.exe` 路径;显式路径配置错误时 +会直接报错,不会掩盖配置问题。 + ## 七个值得一试的玩法 **1. 让 Claude Desktop 成为你的编程 agent。** diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index f2c62f3..5c28b4a 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -9,7 +9,7 @@ import time from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any, BinaryIO +from typing import Any, BinaryIO, Literal from .errors import ToolFailure from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_tail @@ -23,6 +23,22 @@ COMMAND_HEAD_BUFFER_DIVISOR = 8 HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) PWSH_PATH_ENV = "CODING_TOOLS_MCP_PWSH_PATH" +CMD_FALLBACK_WARNING = ( + "PowerShell 7 (pwsh) is unavailable; exec_command is using the cmd.exe " + "compatibility fallback. Commands use cmd.exe syntax, not PowerShell syntax. " + "Install PowerShell 7 or set CODING_TOOLS_MCP_PWSH_PATH to use PowerShell." +) + + +@dataclass(frozen=True) +class WindowsCommandShell: + """Trusted Windows command interpreter selected by the server.""" + + kind: Literal["pwsh", "cmd"] + executable: str + fallback: bool = False + fallback_reason: str | None = None + warning: str | None = None def _environment_value(env: Mapping[str, str], target: str) -> str | None: @@ -93,7 +109,7 @@ def resolve_pwsh() -> str: if executable is None: raise ToolFailure( "SHELL_NOT_FOUND", - "PowerShell 7 is required for Windows string commands, but pwsh was not found on PATH.", + "PowerShell 7 was not found on the server process PATH.", category="runtime", details={ "executable": "pwsh", @@ -114,6 +130,109 @@ def resolve_pwsh() -> str: return executable +def resolve_cmd() -> str: + """Resolve cmd.exe from trusted server-process Windows locations.""" + + raw_candidates: list[str] = [] + comspec = (_environment_value(os.environ, "COMSPEC") or "").strip().strip('"') + if comspec: + raw_candidates.append(comspec) + for variable in ("SYSTEMROOT", "WINDIR"): + root = (_environment_value(os.environ, variable) or "").strip().strip('"') + if root: + raw_candidates.append(ntpath.join(root, "System32", "cmd.exe")) + + seen: set[str] = set() + for raw_candidate in raw_candidates: + candidate = ntpath.normpath(ntpath.expandvars(raw_candidate)) + normalized = ntpath.normcase(candidate) + if normalized in seen: + continue + seen.add(normalized) + if ( + ntpath.isabs(candidate) + and ntpath.basename(candidate).lower() == "cmd.exe" + and os.path.isfile(candidate) + ): + return candidate + + executable = _find_windows_executable_on_path( + "cmd.exe", + _environment_value(os.environ, "PATH"), + ) + if executable is not None: + return executable + raise ToolFailure( + "SHELL_NOT_FOUND", + "Neither PowerShell 7 nor cmd.exe could be resolved for Windows string commands.", + category="runtime", + details={ + "executable": "cmd.exe", + "retry_hint": ( + "Restore the Windows command processor or install PowerShell 7 and add " + f"pwsh to PATH (or set {PWSH_PATH_ENV})." + ), + }, + ) + + +def resolve_windows_command_shell() -> WindowsCommandShell: + """Prefer PowerShell 7 and automatically retain cmd.exe compatibility.""" + + configured_pwsh = bool((_environment_value(os.environ, PWSH_PATH_ENV) or "").strip()) + try: + return WindowsCommandShell(kind="pwsh", executable=resolve_pwsh()) + except ToolFailure as exc: + # An explicit pin is operator intent. Falling back would hide a typo or + # silently ignore a version constraint the operator expected to hold. + if configured_pwsh: + raise + return WindowsCommandShell( + kind="cmd", + executable=resolve_cmd(), + fallback=True, + fallback_reason=exc.code, + warning=CMD_FALLBACK_WARNING, + ) + + +_selected_shell_lock = threading.Lock() +_selected_shell: WindowsCommandShell | ToolFailure | None = None + + +def selected_windows_command_shell(*, refresh: bool = False) -> WindowsCommandShell: + """Resolve the Windows shell once per process and pin the answer. + + Re-resolving per call would let a transient probe failure flip one exec to + cmd.exe while the next uses pwsh, and would let concurrent requests each + start their own multi-second version probe before command accounting sees + them. The lock serializes the first resolution; every later call reuses the + pinned selection (including a pinned failure) until ``refresh`` — used by + check_exec_environment so operators can pick up a newly installed shell + without restarting the server. + """ + + global _selected_shell + with _selected_shell_lock: + if refresh or _selected_shell is None: + try: + _selected_shell = resolve_windows_command_shell() + except ToolFailure as failure: + _selected_shell = failure + pinned = _selected_shell + if isinstance(pinned, ToolFailure): + raise pinned + return pinned + + +def _reset_selected_windows_command_shell() -> None: + """Drop the pinned shell selection (test seam).""" + + global _selected_shell + with _selected_shell_lock: + _selected_shell = None + + @functools.lru_cache(maxsize=16) def pwsh_major_version(executable: str) -> int: """Return and cache the major version reported by a pwsh executable.""" @@ -172,6 +291,20 @@ def build_pwsh_argv(executable: str, command: str) -> list[str]: ] +def build_cmd_command_line(executable: str, command: str) -> str: + """Build the exact cmd.exe command line, bypassing MS CRT argv quoting. + + Passing an argument list would make Popen serialize it with list2cmdline, + whose ``\\"`` escapes reach cmd.exe verbatim and corrupt any command that + contains quotes. ``/S /C`` is specified against a raw command line instead: + cmd strips the first and last quote and executes everything between them + unchanged, so the payload needs exactly one enclosing quote pair and no + further escaping. + """ + + return f'"{executable}" /D /V:OFF /S /C "{command}"' + + def terminate_process_group( process: subprocess.Popen[bytes], signum: signal.Signals, @@ -220,12 +353,17 @@ def spawn_process( env: dict[str, str], tty: bool, popen_kwargs: dict[str, Any], + windows_shell: WindowsCommandShell | None = None, ) -> tuple[subprocess.Popen[bytes], int | None]: """Spawn a pipe-backed or true POSIX PTY-backed process.""" if not tty: if os.name == "nt" and shell and isinstance(command, str): - command = build_pwsh_argv(resolve_pwsh(), command) + selected_shell = windows_shell or selected_windows_command_shell() + if selected_shell.kind == "pwsh": + command = build_pwsh_argv(selected_shell.executable, command) + else: + command = build_cmd_command_line(selected_shell.executable, command) shell = False process = subprocess.Popen( command, diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index cd156e2..ffaaffe 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -62,6 +62,8 @@ COMMAND_BUFFER_BYTES, COMMAND_HEAD_BUFFER_DIVISOR, CommandRun, + WindowsCommandShell, + selected_windows_command_shell, spawn_process, start_reader_threads, start_command_watchdog, @@ -188,7 +190,7 @@ class ModeCapabilities: re.I, ) POWERSHELL_NETWORK_RE = re.compile( - r"(?:^|[;&|{}\r\n])\s*(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" + r"(?:^|[;&|{}\r\n])\s*(?:[\w.]+[\\/])?(?:Invoke-WebRequest|Invoke-RestMethod|Start-BitsTransfer|" r"Test-NetConnection|Test-Connection|Resolve-DnsName|iwr|irm|tnc|ping(?:\.exe)?|" r"nslookup(?:\.exe)?|tracert(?:\.exe)?)\b|\b(?:System\.)?Net\.", re.I, @@ -198,11 +200,19 @@ class ModeCapabilities: r"(^|\s)(sudo|su|chmod\s+-R|chown\s+-R|mkfs|mount|umount|find\b[^;&|]*\s-delete\b|git\b[^;&|]*\breset\s+--hard\b|git\b[^;&|]*\bclean\s+-[^\s]*[fx][^\s]*|rm\s+-[^\s]*r[^\s]*f|rm\s+-[^\s]*f[^\s]*r)\b", re.I, ) +# A module-qualified spelling (Microsoft.PowerShell.Management\Remove-Item) +# invokes the same cmdlet, so the command-position match accepts an optional +# Module\ prefix in both PowerShell scans above and below. POWERSHELL_DESTRUCTIVE_RE = re.compile( - r"(?:^|[;&|{}\r\n])\s*(?:Remove-Item|rm|ri|del|erase|rmdir|rd)\b" + r"(?:^|[;&|{}\r\n])\s*(?:[\w.]+[\\/])?(?:Remove-Item|rm|ri|del|erase|rmdir|rd)\b" r"(?=[^;&|{}\r\n]*\s-(?:r|re|rec|recu|recur|recurs|recurse)\b)", re.I, ) +# Single-quoted PowerShell strings are inert text ('' is the only escape), so +# the dynamic-syntax scan drops them before matching; a literal '$5' or 'a::b' +# argument is not expansion. Backticks never reach this far in safe mode +# because SHELL_EXPANSION_RE already gates them. +POWERSHELL_SINGLE_QUOTED_RE = re.compile(r"'[^']*(?:''[^']*)*'") # PowerShell resolves commands at runtime, so scanning for cmdlet names cannot # see through a variable, a splatted parameter set, a redefined alias, or a # .NET member call. Any of those constructs makes the destructive and network @@ -217,6 +227,26 @@ class ModeCapabilities: r"Add-Type|Set-Alias|New-Alias|sal|nal)\b)", re.I, ) +# cmd.exe also resolves command text at runtime. Paired %...% expansion, +# CALL/FOR double evaluation, and caret escaping can hide the literal command +# from the scans above, so safe mode treats those constructs like shell +# expansion. A lone % cannot expand on a command line (echo 100%, +# git log --format=%h), and CALL/FOR only evaluate at command position +# (echo call for help stays literal), so neither is gated. +CMD_DYNAMIC_RE = re.compile( + r"(?P%[^%\r\n]+%)" + r"|(?P\^)" + r"|(?P(?:^|[&|()\r\n])\s*(?:call|for)\b)", + re.I, +) +CMD_DESTRUCTIVE_RE = re.compile( + r"(?:^|[&|()\r\n])\s*(?:(?:del|erase|rd|rmdir)\b" + r"(?=[^&|()\r\n]*\s/(?:s|s[q]?|q[s]))" + # format.com and diskpart are real executables, so a path spelling such as + # C:\Windows\System32\format.com invokes them just as well as the bare name. + r"|(?:[^\s&|()\r\n]*[\\/])?(?:format(?:\.com)?|diskpart)(?:\.exe)?(?=$|[\s&|()<>\r\n]))", + re.I, +) MAX_HTTP_REQUEST_BYTES = 1_048_576 EXEC_PREVIEW_BYTES = 4096 MAX_ACTIVE_COMMANDS = 16 @@ -1570,8 +1600,8 @@ def git_path_filter(self, raw_path: str) -> str: return "." return self.resolve_for_write(raw_path).display - def _exec_environment_summary(self) -> dict[str, Any]: - return { + def _exec_environment_summary(self, *, refresh_command_shell: bool = False) -> dict[str, Any]: + summary: dict[str, Any] = { "workspace": str(self.workspace.root), "permission_mode": self.permission_mode, "network_allowed": self.allow_network, @@ -1580,6 +1610,18 @@ def _exec_environment_summary(self) -> dict[str, Any]: "tmpdir": str(self.command_tmp_dir()), "cache_dir": str(self.cache_dir), } + if os.name == "nt": + try: + summary["command_shell"] = windows_command_shell_payload( + selected_windows_command_shell(refresh=refresh_command_shell) + ) + except ToolFailure as exc: + summary["command_shell"] = { + "available": False, + "error_code": exc.code, + "message": exc.message, + } + return summary def _landlock_enforced(self, landlock: dict[str, Any]) -> bool: return bool(landlock.get("available")) and self.landlock_enabled() @@ -1693,6 +1735,10 @@ def server_info(self, args: dict[str, Any]) -> dict[str, Any]: def check_exec_environment(self, args: dict[str, Any]) -> dict[str, Any]: landlock = landlock_status_payload() + # The explicit diagnostic re-resolves the pinned Windows shell so an + # operator who just installed pwsh sees (and activates) it here without + # restarting the server. + summary = self._exec_environment_summary(refresh_command_shell=True) warnings: list[str] = [] if not landlock.get("available"): warnings.append("Linux Landlock filesystem confinement is unavailable") @@ -1702,9 +1748,16 @@ def check_exec_environment(self, args: dict[str, Any]) -> dict[str, Any]: warnings.append( "tools/list annotations are faked as read-only; apply_patch and exec_command still mutate and execute" ) + command_shell = summary.get("command_shell") + if isinstance(command_shell, dict): + shell_warning = command_shell.get("warning") + if isinstance(shell_warning, str): + warnings.append(shell_warning) + elif command_shell.get("available") is False: + warnings.append(str(command_shell.get("message") or "Windows command shell is unavailable")) return { "ok": True, - **self._exec_environment_summary(), + **summary, "landlock_enabled": self._landlock_enforced(landlock), "landlock_abi": landlock.get("abi_version"), "global_tmp_write": self.global_tmp_write_policy(), @@ -2382,11 +2435,12 @@ def exec_command(self, args: dict[str, Any]) -> dict[str, Any]: workdir = self.resolve_existing(str(workdir_arg)) if not workdir.path.is_dir(): raise ToolFailure("NOT_A_DIRECTORY", "workdir is not a directory.", category="validation") - self._check_command_policy(cmd, args) + tty = bool(args.get("tty", False)) + windows_shell = selected_windows_command_shell() if os.name == "nt" and not tty else None + self._check_command_policy(cmd, args, windows_shell=windows_shell) timeout_ms = int(args.get("timeout_ms", 30000)) yield_ms = int(args.get("yield_time_ms", 10000)) max_output_bytes = int(args.get("max_output_bytes", 65536)) - tty = bool(args.get("tty", False)) stdin_text = str(args.get("stdin", "")) env = self._command_env(args.get("env", {})) start = time.time() @@ -2438,11 +2492,20 @@ def exec_command(self, args: dict[str, Any]) -> dict[str, Any]: env=env, tty=tty, popen_kwargs=popen_extra, + windows_shell=windows_shell, ) + command_warnings = [ + warning + for warning in ( + landlock_warning, + windows_shell.warning if windows_shell is not None else None, + ) + if warning + ] command = self._make_command( process, timeout_at=deadline, - warnings=[landlock_warning] if landlock_warning else None, + warnings=command_warnings, pty_master_fd=pty_master_fd, ) with self.commands_lock: @@ -2485,6 +2548,8 @@ def finish() -> dict[str, Any]: # terminated/timeout) so exec, polling, and kill paths agree. payload = command.snapshot_since_cursor(max_output_bytes) payload["elapsed_ms"] = int((time.time() - start) * 1000) + if windows_shell is not None: + payload["command_shell"] = windows_command_shell_payload(windows_shell) self._add_exec_diagnostics(payload) return self._format_command_output(command, payload, args) @@ -2509,10 +2574,30 @@ def finish() -> dict[str, Any]: return finish() time.sleep(0.02) - def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: + def _check_command_policy( + self, + cmd: str, + args: dict[str, Any], + *, + windows_shell: WindowsCommandShell | None = None, + ) -> None: if self.dangerously_skip_all_permissions: return - self._check_command_paths(cmd) + compact = " ".join(cmd.split()).lower() + # POSIX shlex treats backslash as an escape and silently eats it, which + # blinds every token-based check to unquoted Windows paths such as + # C:\Windows\System32\...\powershell.exe. Backslash is a path separator + # on both Windows shells (their escapes are backtick and caret), so the + # token scans run on a slash-normalized copy there. + scan_cmd = cmd.replace("\\", "/") if windows_shell is not None else cmd + if windows_shell is not None and windows_shell.kind == "cmd" and CMD_DESTRUCTIVE_RE.search(cmd): + raise ToolFailure( + "PERMISSION_REQUIRED", + "Destructive commands are blocked without explicit permission.", + category="permission", + details={"permission": "destructive_command", "command": compact}, + ) + self._check_command_paths(scan_cmd) env = args.get("env", {}) if isinstance(env, dict) and any( is_filtered_env_var(str(key), str(value)) for key, value in env.items() @@ -2524,7 +2609,7 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: details={"permission": "sensitive_env", "env_keys": sorted(str(key) for key in env)}, ) if not self.capabilities.inline_script: - inline_script = inline_script_command(cmd) + inline_script = inline_script_command(scan_cmd) if inline_script is not None: raise ToolFailure( "PERMISSION_REQUIRED", @@ -2532,7 +2617,12 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": INLINE_SCRIPT_PERMISSION, **inline_script}, ) - compact = " ".join(cmd.split()).lower() + uses_powershell = powershell_executes_string_commands(windows_shell) + uses_cmd = windows_shell is not None and windows_shell.kind == "cmd" + # The PowerShell scans model PowerShell semantics; running them against + # a POSIX shell or cmd.exe command would gate rm -r and ping on hosts + # where those regexes were never the contract. + scan_powershell = uses_powershell if not self.capabilities.shell_expansion and SHELL_EXPANSION_RE.search(cmd): raise ToolFailure( "PERMISSION_REQUIRED", @@ -2547,15 +2637,20 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: category="permission", details={"permission": "destructive_command", "command": compact}, ) - if DESTRUCTIVE_RE.search(cmd) or POWERSHELL_DESTRUCTIVE_RE.search(cmd): + if ( + DESTRUCTIVE_RE.search(cmd) + or (scan_powershell and POWERSHELL_DESTRUCTIVE_RE.search(cmd)) + ): raise ToolFailure( "PERMISSION_REQUIRED", "Destructive commands are blocked without explicit permission.", category="permission", details={"permission": "destructive_command", "command": compact}, ) - network_command = NETWORK_RE.search(cmd) or POWERSHELL_NETWORK_RE.search(cmd) - if not self.allow_network and network_command and not is_literal_network_reference_command(cmd): + network_command = NETWORK_RE.search(cmd) or ( + scan_powershell and POWERSHELL_NETWORK_RE.search(cmd) + ) + if not self.allow_network and network_command and not is_literal_network_reference_command(scan_cmd): raise ToolFailure( "PERMISSION_REQUIRED", "Network access is denied by default.", @@ -2565,7 +2660,7 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: # Runs last so a command the scans above already recognized keeps its # precise permission label; this is the catch-all for the PowerShell # syntax that makes those scans unsound in the first place. - if not self.capabilities.shell_expansion and powershell_executes_string_commands(): + if not self.capabilities.shell_expansion and uses_powershell: construct = powershell_dynamic_construct(cmd) if construct is not None: raise ToolFailure( @@ -2580,6 +2675,20 @@ def _check_command_policy(self, cmd: str, args: dict[str, Any]) -> None: "command": compact, }, ) + if not self.capabilities.shell_expansion and uses_cmd: + construct = cmd_dynamic_construct(cmd) + if construct is not None: + raise ToolFailure( + "PERMISSION_REQUIRED", + "cmd.exe dynamic syntax requires explicit permission because expansion, " + "double evaluation, and escaping can hide the command from policy scans.", + category="permission", + details={ + "permission": "shell_expansion", + "construct": construct, + "command": compact, + }, + ) def _add_exec_diagnostics(self, payload: dict[str, Any]) -> None: diagnostics = exec_output_diagnostics(payload) @@ -3978,19 +4087,45 @@ def is_inspectable_path_argument(token: str) -> bool: return "." in PurePosixPath(normalized).name -def powershell_executes_string_commands() -> bool: - """True when this host runs string commands through PowerShell 7. +def windows_command_shell_payload(shell: WindowsCommandShell) -> dict[str, Any]: + payload: dict[str, Any] = { + "available": True, + "kind": shell.kind, + "executable": shell.executable, + "fallback": shell.fallback, + } + if shell.fallback_reason is not None: + payload["fallback_reason"] = shell.fallback_reason + if shell.warning is not None: + payload["warning"] = shell.warning + return payload + - Mirrors the spawn decision in processes.spawn_process, which wraps Windows - string commands in pwsh. Command policy has to agree with that decision: +def powershell_executes_string_commands( + windows_shell: WindowsCommandShell | None = None, +) -> bool: + """True when the selected Windows string-command shell is PowerShell 7. + + Command policy has to mirror the selection passed to processes.spawn_process: PowerShell syntax is only worth gating where PowerShell is the interpreter. + The host fallback keeps direct policy-helper tests backward compatible. """ + if windows_shell is not None: + return windows_shell.kind == "pwsh" return os.name == "nt" def powershell_dynamic_construct(command: str) -> str | None: - match = POWERSHELL_DYNAMIC_RE.search(command) + scannable = POWERSHELL_SINGLE_QUOTED_RE.sub("''", command) + match = POWERSHELL_DYNAMIC_RE.search(scannable) + if match is None: + return None + return match.lastgroup + + +def cmd_dynamic_construct(command: str) -> str | None: + match = CMD_DYNAMIC_RE.search(command) if match is None: return None return match.lastgroup diff --git a/docs/ci-and-tests.md b/docs/ci-and-tests.md index 6e632a7..dd32ee0 100644 --- a/docs/ci-and-tests.md +++ b/docs/ci-and-tests.md @@ -116,6 +116,8 @@ Windows reports unsupported TTY requests explicitly, force-kills a background command without relying on POSIX `SIGKILL`, initializes Visual Studio with `vcvarsall.bat x64`, checks the narrow default `core` environment, and confirms that `--shell-env-inherit all` can compile and run a single-file `cl.exe` smoke. +It also exercises PowerShell 7 selection, the trusted `cmd.exe` compatibility +fallback, and the shell-specific safe-mode policy gates. Manual SWE-bench workflow: diff --git a/docs/limitations.md b/docs/limitations.md index 8fd8af8..0170fb2 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -6,11 +6,14 @@ - Non-Linux platforms or Linux kernels without Landlock are not production targets for `exec_command` without an external sandbox. - This build uses real POSIX PTYs but does not implement Windows ConPTY; `tty=true` returns `TTY_UNSUPPORTED` on Windows. -- Windows string commands require PowerShell 7 (`pwsh`) and run with +- Windows string commands prefer PowerShell 7 (`pwsh`) and run it with `-NoLogo -NoProfile -NonInteractive`. Operators may pin an absolute trusted executable with `CODING_TOOLS_MCP_PWSH_PATH`; otherwise the server searches absolute entries on its own process `PATH` while excluding the current - directory tree. There is no `cmd.exe` fallback. + directory tree. If no usable unpinned `pwsh` is found, the server + automatically falls back to a trusted `cmd.exe` resolved from the server + environment or Windows system directory. An invalid explicit PowerShell pin + remains an error. - Windows `safe` mode cannot statically decide what PowerShell dynamic syntax resolves to, so variables (`$`), splatting (`@`), the call and dot-source operators, .NET member access (`::`), and alias or expression evaluation @@ -20,6 +23,11 @@ `request_permissions` or `trusted` mode for commands that need them. Command scanning is not a sandbox: this build has no OS-level confinement on Windows, so `safe` mode there is a best-effort gate rather than a boundary. +- The `cmd.exe` compatibility fallback disables AutoRun and delayed expansion. + In `safe` mode, percent expansion, caret escaping, and `CALL`/`FOR` evaluation + require `shell_expansion`; recursive `del`/`rmdir`, `format`, and `diskpart` + remain permission-gated. These checks are also best-effort rather than an + OS-level boundary. - Portable filesystems do not provide a transaction across unrelated directories. `apply_patch` keeps same-directory backups and rolls back the full staged set, but a storage failure that also prevents rollback is surfaced diff --git a/docs/runtime-contract-v0.2.md b/docs/runtime-contract-v0.2.md index 680ad40..fd85d8b 100644 --- a/docs/runtime-contract-v0.2.md +++ b/docs/runtime-contract-v0.2.md @@ -114,7 +114,7 @@ Tool failures keep the same envelope with `isError: true`, a readable error in Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SHELL_NOT_FOUND", "SHELL_VERSION_UNSUPPORTED", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, diff --git a/docs/runtime-contract-v0.3.md b/docs/runtime-contract-v0.3.md index 5e7005b..eabb0fd 100644 --- a/docs/runtime-contract-v0.3.md +++ b/docs/runtime-contract-v0.3.md @@ -293,7 +293,7 @@ Retry: This command_id has expired or never existed; … Known tool error codes include: ```json -["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] +["ABSOLUTE_PATH_DENIED", "BINARY_FILE", "COMMAND_CLOSED", "COMMAND_LIMIT_REACHED", "COMMAND_NOT_FOUND", "ELICITATION_UNSUPPORTED", "GIT_ERROR", "INTERNAL_ERROR", "INVALID_ARGUMENT", "IS_DIRECTORY", "NOT_A_DIRECTORY", "NOT_FOUND", "OUTPUT_TOO_LARGE", "PATCH_CONFLICT", "PATCH_CONTEXT_AMBIGUOUS", "PATCH_CONTEXT_NOT_FOUND", "PATCH_FAILED", "PATCH_HUNKS_OVERLAP", "PATCH_ROLLBACK_FAILED", "PATH_OUTSIDE_WORKSPACE", "PERMISSION_REQUIRED", "RUNTIME_DIR_UNWRITABLE", "SANDBOX_UNAVAILABLE", "SHELL_NOT_FOUND", "SHELL_VERSION_UNSUPPORTED", "SYMLINK_ESCAPE", "TTY_UNSUPPORTED", "UNSUPPORTED_ENCODING"] ``` Error categories are `validation`, `security`, `permission`, `runtime`, @@ -336,6 +336,13 @@ bounded, all of them per workspace rather than per client. Completed commands have a TTL. POSIX `tty=true` uses a real pseudo-terminal; Windows reports `TTY_UNSUPPORTED` in this build instead of pretending pipes are a TTY. +Windows string commands prefer a server-resolved PowerShell 7 (`pwsh`) and +automatically use a trusted `cmd.exe` compatibility fallback when no usable +unpinned PowerShell is available. An invalid +`CODING_TOOLS_MCP_PWSH_PATH` remains an error. Windows `exec_command` results +include a structured `command_shell`; fallback results also include a warning +so an agent can use cmd syntax rather than retrying PowerShell syntax. + ## HTTP authentication Non-loopback deployment requires bearer or OAuth authentication unless the @@ -375,7 +382,8 @@ Annotations: `{"title":"Server info","readOnlyHint":true,"destructiveHint":false Returns server version, `supported_protocol_versions`, workspace, fixed tool count, auth state, permission mode, runtime directories, project-context -metadata, exec policy, and the static retained-output budget. It reports no +metadata, exec policy, the selected Windows `command_shell` when applicable, +and the static retained-output budget. It reports no per-session value and no runtime counter: there is no session, and how often a budget was hit is a property of the process rather than an answer to whichever client asked. Those counters travel with telemetry. @@ -386,7 +394,8 @@ Inputs: none. Annotations: `{"title":"Check exec environment","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}`. -Returns lightweight policy and Landlock status without running active probes. +Returns lightweight policy, Landlock status, and the selected Windows command +shell. A `cmd.exe` fallback is repeated in `warnings`. ### read_file diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f0bc4c9..f724d56 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -22,6 +22,26 @@ If `exec_command` returns a warning about Linux Landlock being unavailable, the If an older client or server reports `SANDBOX_UNAVAILABLE` as an error, upgrade to the current behavior or run on a Landlock-capable Linux kernel. +## Windows Command Shell + +Windows string commands prefer PowerShell 7 (`pwsh`). If it is not installed or +an unpinned copy cannot be verified, the server automatically uses the trusted +Windows `cmd.exe` compatibility fallback. The selected interpreter appears as +`command_shell` in `server_info`, `check_exec_environment`, and +`exec_command`; fallback results also include a warning so the agent can switch +to cmd syntax. + +The selection is resolved once and pinned for the server process, so concurrent +commands always agree on one interpreter. `check_exec_environment` re-resolves +the pin: after installing PowerShell 7, run that tool (or restart the server) to +leave the `cmd.exe` fallback. + +To require a specific PowerShell installation, set +`CODING_TOOLS_MCP_PWSH_PATH` to its absolute `pwsh.exe` path. A bad explicit pin +returns `SHELL_NOT_FOUND` or `SHELL_VERSION_UNSUPPORTED` instead of falling back. +If neither interpreter can be resolved, restore `cmd.exe` or install PowerShell +7. + ## Command Hangs Or Times Out If the result returns `status: "running"`, poll with `write_stdin` using empty `chars`, or terminate with `kill_command`. Command deadlines still apply when the client stops polling. diff --git a/tests/compliance/test_windows_msvc_smoke.py b/tests/compliance/test_windows_msvc_smoke.py index 6e40269..9a93ac5 100644 --- a/tests/compliance/test_windows_msvc_smoke.py +++ b/tests/compliance/test_windows_msvc_smoke.py @@ -132,7 +132,10 @@ def test_inherit_all_preserves_msvc_environment_for_single_file_compile(self) -> run_result = client.call_tool( "exec_command", { - "cmd": "hello.exe", + # PowerShell intentionally does not search the current + # directory for executables; this spelling also works + # unchanged under the cmd.exe compatibility fallback. + "cmd": r".\hello.exe", "timeout_ms": 30000, "yield_time_ms": 30000, "max_output_bytes": 20000, diff --git a/tests/test_windows_pwsh.py b/tests/test_windows_pwsh.py index 732e96a..dae98f8 100644 --- a/tests/test_windows_pwsh.py +++ b/tests/test_windows_pwsh.py @@ -11,10 +11,21 @@ from coding_tools_mcp.errors import ToolFailure from coding_tools_mcp.server import Runtime, ShellEnvPolicy +PWSH_SHELL = processes.WindowsCommandShell( + kind="pwsh", + executable=r"C:\Program Files\PowerShell\7\pwsh.exe", +) +CMD_SHELL = processes.WindowsCommandShell( + kind="cmd", + executable=r"C:\Windows\System32\cmd.exe", + fallback=True, +) + class WindowsPowerShellSpawnTests(unittest.TestCase): def tearDown(self) -> None: processes.pwsh_major_version.cache_clear() + processes._reset_selected_windows_command_shell() def test_windows_string_command_uses_server_resolved_noninteractive_pwsh(self) -> None: captured: dict[str, object] = {} @@ -64,6 +75,63 @@ def fake_popen(command: object, **kwargs: object) -> FakeProcess: self.assertIs(captured["shell"], False) self.assertEqual(captured["env"], {"Path": attacker_path}) + def test_windows_string_command_falls_back_to_trusted_cmd(self) -> None: + captured: dict[str, object] = {} + + class FakeProcess: + pass + + def fake_popen(command: object, **kwargs: object) -> FakeProcess: + captured["command"] = command + captured.update(kwargs) + return FakeProcess() + + trusted = r"C:\Windows\System32\cmd.exe" + with ( + patch.object(processes.os, "name", "nt"), + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict( + processes.os.environ, + { + "COMSPEC": trusted, + "Path": r"C:\Windows\System32", + "SystemRoot": r"C:\Windows", + }, + clear=True, + ), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == trusted), + patch.object(processes.subprocess, "Popen", side_effect=fake_popen), + ): + process, pty_fd = processes.spawn_process( + "echo ok", + cwd=r"C:\workspace", + shell=True, + env={"Path": r"C:\workspace\bin"}, + tty=False, + popen_kwargs={}, + ) + + self.assertIsInstance(process, FakeProcess) + self.assertIsNone(pty_fd) + self.assertEqual( + captured["command"], + f'"{trusted}" /D /V:OFF /S /C "echo ok"', + ) + self.assertIs(captured["shell"], False) + self.assertEqual(captured["env"], {"Path": r"C:\workspace\bin"}) + + def test_cmd_command_line_preserves_embedded_quotes_verbatim(self) -> None: + # An argv list would be re-serialized with MS CRT rules and reach + # cmd.exe as \" sequences, corrupting any quoted argument. The raw + # /S /C command line must carry the payload byte-for-byte. + command = 'type "C:\\Program Files\\notes.txt" > "out dir\\copy.txt"' + line = processes.build_cmd_command_line(r"C:\Windows\System32\cmd.exe", command) + self.assertEqual( + line, + '"C:\\Windows\\System32\\cmd.exe" /D /V:OFF /S /C ' + '"type "C:\\Program Files\\notes.txt" > "out dir\\copy.txt""', + ) + def test_windows_pwsh_resolution_skips_current_directory_entries(self) -> None: trusted = r"C:\Program Files\PowerShell\7\pwsh.exe" malicious = r"C:\workspace\pwsh.exe" @@ -100,17 +168,44 @@ def test_windows_pwsh_rejects_invalid_explicit_path(self) -> None: self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") - def test_windows_string_command_requires_pwsh(self) -> None: + def test_invalid_explicit_pwsh_pin_does_not_fall_back(self) -> None: with ( - patch.object(processes.os, "name", "nt"), - patch.object(processes.os, "getcwd", return_value=r"C:\server"), - patch.dict(processes.os.environ, {"Path": r"C:\Windows\System32"}, clear=True), + patch.dict( + processes.os.environ, + {processes.PWSH_PATH_ENV: r".\pwsh.exe"}, + clear=True, + ), patch.object(processes.os.path, "isfile", return_value=False), + patch.object(processes, "resolve_cmd") as resolve_cmd, ): with self.assertRaises(ToolFailure) as raised: - processes.resolve_pwsh() + processes.resolve_windows_command_shell() self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + resolve_cmd.assert_not_called() + + def test_windows_shell_falls_back_when_pwsh_is_missing(self) -> None: + cmd = r"C:\Windows\System32\cmd.exe" + with ( + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict( + processes.os.environ, + { + "COMSPEC": cmd, + "Path": r"C:\Windows\System32", + "SystemRoot": r"C:\Windows", + }, + clear=True, + ), + patch.object(processes.os.path, "isfile", side_effect=lambda path: path == cmd), + ): + selected = processes.resolve_windows_command_shell() + + self.assertEqual(selected.kind, "cmd") + self.assertEqual(selected.executable, cmd) + self.assertTrue(selected.fallback) + self.assertEqual(selected.fallback_reason, "SHELL_NOT_FOUND") + self.assertIn("cmd.exe syntax", selected.warning or "") def test_windows_string_command_rejects_powershell_older_than_7(self) -> None: executable = r"C:\PowerShell\6\pwsh.exe" @@ -126,6 +221,42 @@ def test_windows_string_command_rejects_powershell_older_than_7(self) -> None: self.assertEqual(raised.exception.code, "SHELL_VERSION_UNSUPPORTED") + def test_unpinned_unsupported_pwsh_falls_back_to_cmd(self) -> None: + pwsh = r"C:\PowerShell\6\pwsh.exe" + cmd = r"C:\Windows\System32\cmd.exe" + with ( + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict( + processes.os.environ, + { + "COMSPEC": cmd, + "Path": r"C:\PowerShell\6;C:\Windows\System32", + }, + clear=True, + ), + patch.object( + processes.os.path, + "isfile", + side_effect=lambda path: path in {pwsh, cmd}, + ), + patch.object(processes, "pwsh_major_version", return_value=6), + ): + selected = processes.resolve_windows_command_shell() + + self.assertEqual(selected.kind, "cmd") + self.assertEqual(selected.fallback_reason, "SHELL_VERSION_UNSUPPORTED") + + def test_windows_shell_reports_error_when_neither_interpreter_exists(self) -> None: + with ( + patch.object(processes.os, "getcwd", return_value=r"C:\server"), + patch.dict(processes.os.environ, {"Path": r"C:\empty"}, clear=True), + patch.object(processes.os.path, "isfile", return_value=False), + ): + with self.assertRaises(ToolFailure) as raised: + processes.resolve_windows_command_shell() + + self.assertEqual(raised.exception.code, "SHELL_NOT_FOUND") + @unittest.skipUnless(os.name == "nt", "requires Windows PowerShell 7") def test_runtime_executes_bare_powershell_syntax(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -150,6 +281,150 @@ def test_runtime_executes_bare_powershell_syntax(self) -> None: self.assertEqual(result.get("exit_code"), 0, result) self.assertIn("PS_MAJOR=7", str(result.get("stdout", ""))) + @unittest.skipUnless(os.name == "nt", "requires Windows cmd.exe") + def test_runtime_cmd_fallback_is_visible_and_executes(self) -> None: + cmd = os.environ["COMSPEC"] + selected = processes.WindowsCommandShell( + kind="cmd", + executable=cmd, + fallback=True, + fallback_reason="SHELL_NOT_FOUND", + warning=processes.CMD_FALLBACK_WARNING, + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime( + Path(tmp), + permission_mode="trusted", + shell_env_policy=ShellEnvPolicy(inherit="all"), + ) + try: + with patch.object( + server, "selected_windows_command_shell", return_value=selected + ): + result = runtime.exec_command( + { + "cmd": "echo CMD_FALLBACK_OK", + "timeout_ms": 30_000, + "yield_time_ms": 30_000, + "verbosity": "full", + } + ) + finally: + runtime.close() + + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("CMD_FALLBACK_OK", str(result.get("stdout", ""))) + self.assertEqual(result.get("command_shell", {}).get("kind"), "cmd") + self.assertTrue(result.get("command_shell", {}).get("fallback")) + self.assertTrue( + any("cmd.exe compatibility fallback" in warning for warning in result.get("warnings", [])), + result, + ) + + @unittest.skipUnless(os.name == "nt", "requires Windows cmd.exe") + def test_runtime_real_cmd_fallback_without_pwsh_on_path(self) -> None: + # No mocked resolver or Popen: hide every PATH entry that offers + # pwsh.exe, let selection fall back to the real cmd.exe, and run a + # command whose quoted argument would be corrupted by argv re-quoting. + def offers_pwsh(entry: str) -> bool: + candidate = entry.strip().strip('"') + if not candidate: + return False + try: + return (Path(candidate) / "pwsh.exe").is_file() + except OSError: + return False + + stripped_path = ";".join( + entry for entry in os.environ.get("PATH", "").split(";") if not offers_pwsh(entry) + ) + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / "has space.txt").write_text("FALLBACK_QUOTES_OK", encoding="utf-8") + runtime = Runtime( + Path(tmp), + permission_mode="trusted", + shell_env_policy=ShellEnvPolicy(inherit="all"), + ) + try: + with patch.dict(os.environ, {"PATH": stripped_path}, clear=False): + os.environ.pop(processes.PWSH_PATH_ENV, None) + processes._reset_selected_windows_command_shell() + result = runtime.exec_command( + { + "cmd": 'type "has space.txt"', + "timeout_ms": 30_000, + "yield_time_ms": 30_000, + "verbosity": "full", + } + ) + finally: + processes._reset_selected_windows_command_shell() + runtime.close() + + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("FALLBACK_QUOTES_OK", str(result.get("stdout", ""))) + self.assertEqual(result.get("command_shell", {}).get("kind"), "cmd") + self.assertTrue(result.get("command_shell", {}).get("fallback"), result) + + def test_server_info_and_exec_check_disclose_cmd_fallback(self) -> None: + selected = processes.WindowsCommandShell( + kind="cmd", + executable=r"C:\Windows\System32\cmd.exe", + fallback=True, + fallback_reason="SHELL_NOT_FOUND", + warning=processes.CMD_FALLBACK_WARNING, + ) + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp)) + try: + summary = {"command_shell": server.windows_command_shell_payload(selected)} + with patch.object(runtime, "_exec_environment_summary", return_value=summary): + info = runtime.server_info_payload() + check = runtime.check_exec_environment({}) + finally: + runtime.close() + + self.assertEqual(info.get("command_shell", {}).get("kind"), "cmd") + self.assertTrue(info.get("command_shell", {}).get("fallback")) + self.assertTrue( + any("cmd.exe compatibility fallback" in warning for warning in check.get("warnings", [])), + check, + ) + + +class WindowsShellSelectionCacheTests(unittest.TestCase): + def tearDown(self) -> None: + processes._reset_selected_windows_command_shell() + + def test_selection_is_pinned_for_the_process(self) -> None: + with patch.object( + processes, "resolve_windows_command_shell", return_value=PWSH_SHELL + ) as resolver: + first = processes.selected_windows_command_shell() + second = processes.selected_windows_command_shell() + + self.assertIs(first, PWSH_SHELL) + self.assertIs(second, PWSH_SHELL) + resolver.assert_called_once() + + def test_selection_failure_is_pinned_until_refresh(self) -> None: + failure = ToolFailure("SHELL_NOT_FOUND", "no shell", category="runtime") + with patch.object( + processes, + "resolve_windows_command_shell", + side_effect=[failure, CMD_SHELL], + ) as resolver: + with self.assertRaises(ToolFailure): + processes.selected_windows_command_shell() + # A pinned failure must not re-probe on the exec path. + with self.assertRaises(ToolFailure): + processes.selected_windows_command_shell() + self.assertEqual(resolver.call_count, 1) + refreshed = processes.selected_windows_command_shell(refresh=True) + + self.assertIs(refreshed, CMD_SHELL) + self.assertEqual(resolver.call_count, 2) + class WindowsPowerShellPolicyTests(unittest.TestCase): def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) -> None: @@ -167,6 +442,8 @@ def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) "Resolve-DnsName example.com", "[System.Net.Dns]::GetHostAddresses('example.com')", "Write-Output ok\nInvoke-WebRequest -Uri example.com", + # Module-qualified invocation runs the same cmdlet. + "Microsoft.PowerShell.Utility\\Invoke-WebRequest -Uri example.com", ) with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="safe") @@ -174,7 +451,7 @@ def test_safe_mode_blocks_powershell_network_commands_without_url_literals(self) for command in commands: with self.subTest(command=command): with self.assertRaises(ToolFailure) as raised: - runtime._check_command_policy(command, {}) + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) self.assertEqual(raised.exception.details.get("permission"), "network") finally: runtime.close() @@ -192,6 +469,8 @@ def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviation "del -Recurse .", "rmdir -r .", "Write-Output ok\r\nRemove-Item -Recurse .", + # Module-qualified invocation runs the same cmdlet. + "Microsoft.PowerShell.Management\\Remove-Item -Recurse -Force build", ) with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="safe") @@ -199,7 +478,7 @@ def test_safe_mode_blocks_recursive_powershell_deletion_aliases_and_abbreviation for command in commands: with self.subTest(command=command): with self.assertRaises(ToolFailure) as raised: - runtime._check_command_policy(command, {}) + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) self.assertEqual( raised.exception.details.get("permission"), "destructive_command", @@ -216,11 +495,10 @@ def assert_policy(self, command: str, *, mode: str = "safe") -> ToolFailure | No with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode=mode) try: - with patch.object(server, "powershell_executes_string_commands", return_value=True): - try: - runtime._check_command_policy(command, {}) - except ToolFailure as failure: - return failure + try: + runtime._check_command_policy(command, {}, windows_shell=PWSH_SHELL) + except ToolFailure as failure: + return failure finally: runtime.close() return None @@ -252,6 +530,10 @@ def test_safe_mode_gates_nested_shells_that_smuggle_encoded_scripts(self) -> Non "pwsh -Command Invoke-WebRequest example.com", "powershell.exe -c whoami", "cmd /c del /s /q C:\\data", + # Unquoted absolute interpreter paths must not slip past the gate + # just because POSIX tokenization eats backslashes. + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -EncodedCommand SQBuAHYAbwBrAGUA", + "C:\\Windows\\System32\\cmd.exe /c del /s /q data", ): with self.subTest(command=command): failure = self.assert_policy(command) @@ -267,6 +549,9 @@ def test_safe_mode_still_allows_literal_powershell_commands(self) -> None: "git config user.email dev@example.com", ".\\build.exe --release", "Get-Content README.md | Select-String planet.txt", + # Single-quoted PowerShell strings are inert: no expansion happens. + "Write-Output '$5'", + "git commit -m 'refs #12 :: costs $5'", ): with self.subTest(command=command): self.assertIsNone(self.assert_policy(command), f"{command!r} was blocked in safe mode") @@ -281,14 +566,98 @@ def test_network_scan_does_not_flag_words_ending_in_net(self) -> None: self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("New-Object System.Net.WebClient")) self.assertIsNotNone(server.POWERSHELL_NETWORK_RE.search("[Net.Dns]::GetHostAddresses('a')")) - def test_posix_hosts_keep_their_existing_expansion_policy(self) -> None: + +class PosixPolicyIsolationTests(unittest.TestCase): + """Without a Windows shell the PowerShell and cmd.exe scans must stay off: + they would otherwise rewrite the POSIX policy contract (rm -r without -f + and ping were never gated there).""" + + def assert_policy(self, command: str) -> ToolFailure | None: with tempfile.TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="safe") try: - with patch.object(server, "powershell_executes_string_commands", return_value=False): - runtime._check_command_policy("echo $HOME", {}) + try: + runtime._check_command_policy(command, {}) + except ToolFailure as failure: + return failure finally: runtime.close() + return None + + def test_posix_hosts_keep_their_existing_expansion_policy(self) -> None: + self.assertIsNone(self.assert_policy("echo $HOME")) + + def test_posix_hosts_do_not_inherit_powershell_scans(self) -> None: + for command in ("rm -r build", "ping example.com", "Remove-Item -Recurse ."): + with self.subTest(command=command): + self.assertIsNone( + self.assert_policy(command), + f"{command!r} was blocked by a Windows-only scan on POSIX", + ) + + +class WindowsCmdPolicyTests(unittest.TestCase): + def assert_policy(self, command: str, *, mode: str = "safe") -> ToolFailure | None: + with tempfile.TemporaryDirectory() as tmp: + runtime = Runtime(Path(tmp), permission_mode=mode) + try: + try: + runtime._check_command_policy(command, {}, windows_shell=CMD_SHELL) + except ToolFailure as failure: + return failure + finally: + runtime.close() + return None + + def test_safe_mode_blocks_recursive_cmd_deletion(self) -> None: + for command in ( + "del /s /q data", + "rmdir /s /q build", + "format.com D:", + "format D:", + "diskpart", + # Path spellings invoke the same executables. + "C:\\Windows\\System32\\format.com D:", + ): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure) + assert failure is not None + self.assertEqual(failure.details.get("permission"), "destructive_command") + + def test_safe_mode_gates_cmd_dynamic_syntax(self) -> None: + cases = { + "%COMSPEC% /c echo ok": "expansion", + "echo %PATH%": "expansion", + "c^u^r^l example.com": "escape", + "call tool.cmd": "dynamic_eval", + "for %f in (*.txt) do type %f": "dynamic_eval", + } + for command, construct in cases.items(): + with self.subTest(command=command): + failure = self.assert_policy(command) + self.assertIsNotNone(failure) + assert failure is not None + self.assertEqual(failure.details.get("permission"), "shell_expansion") + self.assertEqual(failure.details.get("construct"), construct) + + def test_safe_mode_allows_literal_cmd_commands(self) -> None: + for command in ( + "echo ok", + "git status", + "hello.exe", + "cl.exe /nologo hello.c", + # A lone % cannot expand on a cmd command line. + "git log --format=%h", + "echo 100%", + # CALL and FOR only evaluate at command position. + "echo call for help", + ): + with self.subTest(command=command): + self.assertIsNone(self.assert_policy(command), f"{command!r} was blocked in safe mode") + + def test_trusted_mode_allows_cmd_dynamic_syntax(self) -> None: + self.assertIsNone(self.assert_policy("%COMSPEC% /c echo ok", mode="trusted")) if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index bb03c28..a6bffa2 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "coding-tools-mcp" -version = "0.2.2" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pyjwt" }, @@ -61,7 +61,6 @@ desktop = [ ] dev = [ { name = "mypy" }, - { name = "pyyaml" }, { name = "ruff" }, { name = "typing-extensions" }, ] @@ -76,7 +75,6 @@ requires-dist = [ { name = "psutil", marker = "extra == 'desktop'", specifier = ">=7.0,<8" }, { name = "pyjwt", specifier = ">=2.8" }, { name = "pyside6", marker = "extra == 'desktop'", specifier = ">=6.8,<6.9" }, - { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12" }, ] @@ -393,61 +391,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/5d8c6da7586e57ee032643e0c0e62335ef1a1add1a980160ddd1654f1d8d/PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07", size = 72191029, upload-time = "2025-03-27T12:15:10.425Z" }, ] -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - [[package]] name = "ruff" version = "0.15.17" From d697470efe274c7937cda6d5c68cb63650029553 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:47:33 +0000 Subject: [PATCH 4/5] Keep the PR branch's workflow triggers verbatim The maintainer push credential has no workflows permission, and the trigger update this merge carried already lives on main; merging the PR restores it without the fork branch having to change the file. Co-authored-by: Claude Fable 5 --- .github/workflows/compliance.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 37ccdeb..6445449 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -2,9 +2,9 @@ name: compliance on: push: - branches: [main, "recover-*", "release/**", "hotfix/**"] + branches: [main, "recover-*"] pull_request: - branches: [main, "release/**"] + branches: [main] workflow_dispatch: workflow_call: From a1c10320f1e23398c9a3ca037209bcc9650f5417 Mon Sep 17 00:00:00 2001 From: cf-pages <80505777+cf-pages@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:54:26 +0000 Subject: [PATCH 5/5] Restore main's workflow triggers for the CI mirror branch --- .github/workflows/compliance.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 6445449..8fd2eb5 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -2,9 +2,9 @@ name: compliance on: push: - branches: [main, "recover-*"] + branches: [main, "recover-*", "release/**", "hotfix/**"] pull_request: - branches: [main] + branches: [main, "release/**"] workflow_dispatch: workflow_call: @@ -123,6 +123,4 @@ jobs: run: | call "%VCVARSALL%" x64 where cl.exe - python -m unittest ^ - tests.compliance.test_windows_msvc_smoke ^ - tests.test_windows_pwsh + python -m unittest tests.compliance.test_windows_msvc_smoke