Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
159 commits
Select commit Hold shift + click to select a range
c715fc4
feat(cli): nest service commands under 'service secure-desktop'
claude May 13, 2026
fc4a3d6
docs: update remaining 'service install' references to 'service secur…
claude May 13, 2026
5a1faad
fix: import Center in desktop/service.py for UAC tree builder
claude May 13, 2026
8a546c9
feat(service): drop PromptOnSecureDesktop=0 install side-effect
claude May 13, 2026
ad7ce2b
feat(service): add WINDOWS_MCP_SECURE_DESKTOP_POLICY + server-side en…
claude May 13, 2026
53f6593
feat(tools): add WaitForUACPrompt MCP tool
claude May 13, 2026
61ff807
feat(desktop): route Type and Drag through service on Secure Desktop
claude May 13, 2026
582605a
feat(service): replace NULL pipe DACL with SYSTEM + console-user ACL
claude May 13, 2026
a385ce2
feat(service): refuse install when binary path is user-writable
claude May 13, 2026
735a92e
test(vm-e2e): in-VM MCP-client harness driving WaitForUACPrompt
claude May 13, 2026
e617ad1
test(vm-e2e): add path B — Linux-side HTTP MCP client driver
claude May 13, 2026
c8b8f72
fix(vm-e2e): correct vncdotool command name + reroute via Start menu
claude May 14, 2026
6bdc0de
test(vm-e2e): add kickoff.bat launcher under tests/manual/vm_e2e
claude May 14, 2026
e6fc35c
test(vm-e2e): expand suite to actually click Yes + verify policy enfo…
claude May 14, 2026
abbb7f4
test(vm-e2e): mirror kickoff.bat as run-tests.bat (no underscores in …
claude May 14, 2026
e73467a
fix(vm-e2e): make bootstrap robust to MS Store python stub + subshell…
claude May 14, 2026
b0dad84
chore(vm-e2e): gitignore per-run artifacts (results.json, *.log)
claude May 14, 2026
a09b13f
fix(vm-e2e): force TLS 1.2 before downloading uv install script
claude May 14, 2026
3154501
fix(vm-e2e): write run_all.log locally then mirror to share
claude May 14, 2026
11b2ea4
fix(vm-e2e): drop winget+Python; let uv install/manage its own Python
claude May 14, 2026
bacb51a
fix(vm-e2e): accept all certs in test VM (sandbox MITM proxy)
claude May 14, 2026
32fefcd
fix(vm-e2e): pre-stage uv + Python installer on share (proxy-free boo…
claude May 14, 2026
41b9735
fix(vm-e2e): treat stderr from native commands as info, not error
claude May 14, 2026
7bc9ada
fix(vm-e2e): UV_INSECURE_HOST to bypass MITM proxy for uv sync
claude May 14, 2026
8b882fa
fix(cli): replace Unicode arrows/em-dashes with ASCII in click.echo
claude May 14, 2026
2a7a068
fix(vm-e2e): stop+delete prior service before re-staging the repo
claude May 14, 2026
a73699b
fix(vm-e2e): enable UAC + reboot if dockur disabled it
claude May 14, 2026
d0deb3a
fix(vm-e2e): swallow schtasks stderr via cmd /c (avoid PS error promo…
claude May 14, 2026
55ea7fa
test(vm-e2e): include MCP tool error/reason in WaitForUACPrompt asser…
claude May 14, 2026
0101de5
fix(service): retry pipe Open on transient ERROR_FILE_NOT_FOUND
claude May 14, 2026
bbf1bd2
fix(vm-e2e): fire UAC trigger via schtasks /IT to escape elevation in…
claude May 14, 2026
dd39d7d
fix(vm-e2e): switch UAC trigger to runas /trustlevel for non-elevated…
claude May 14, 2026
15c7cba
fix(vm-e2e): launch mcp_client.py at medium integrity via runas /trus…
claude May 14, 2026
e04643d
fix(vm-e2e): wrap medium-int test invocation in batch file (no nested…
claude May 14, 2026
c599241
fix(vm-e2e): force ConsentPromptBehaviorAdmin=2 so Windows binaries t…
claude May 14, 2026
256a020
refactor(vm-e2e): split harness into one-time setup + verify-only test
claude May 14, 2026
d444dca
chore(vm-e2e): drop pre-refactor harness cruft
claude May 14, 2026
38520c3
fix(service): wait_for_uac_prompt must attach to WinSta0 first
claude May 14, 2026
32facab
fix(service): retry uia tree fetch + fix test.ps1 server probe
claude May 14, 2026
edbca0f
fix(vm-e2e): wait for WindowsMCPHost to actually stop before re-staging
claude May 14, 2026
4de4654
feat(service): user-session worker for Winlogon UIA (Session 0 isolat…
claude May 14, 2026
015e50d
fix(service): drop CREATE_NEW_CONSOLE from CreateProcessAsUser flags
claude May 14, 2026
5cc2aa4
feat(service): UIAccess-signed worker for cross-integrity Winlogon UIA
claude May 15, 2026
176498f
feat(service): route all UIA dispatch via user-session worker + VM bu…
claude May 15, 2026
22ee931
feat(service): in-CLI build + self-sign of UIAccess worker
claude May 15, 2026
787dd30
fix(service): use -File + named params for PowerShell calls
claude May 15, 2026
f6aa88d
fix(service): forward UV_INSECURE_HOST to pip when bootstrapping PyIn…
claude May 15, 2026
f88030d
fix(service): use uv pip install for PyInstaller bootstrap
claude May 15, 2026
4f153f3
fix(service): search per-user uv install locations when bootstrapping…
claude May 15, 2026
425c293
fix(service): drop --collect-submodules comtypes from PyInstaller build
claude May 15, 2026
d9d03d2
fix(service): aggressive --exclude-module for PyInstaller analysis
claude May 15, 2026
d17a468
fix(vm-e2e): add Defender exclusion for PyInstaller build path
claude May 15, 2026
aca9f1b
fix(vm-e2e): force fresh wheel + INFO-level pyinstaller, 15min timeout
claude May 15, 2026
3af2dbc
fix(service): bootstrap pip via ensurepip in uv-managed venvs
claude May 15, 2026
763696b
fix(service): flush pipe buffer before DisconnectNamedPipe
claude May 15, 2026
1904082
fix(service): CreateEnvironmentBlock lives in win32profile, not win32…
claude May 15, 2026
1dd44b7
chore(gitignore): ignore vm_e2e dev helpers
claude May 15, 2026
e181adf
test(vm-e2e): retry WindowsMCPHost check for up to 60s
claude May 15, 2026
67dc466
diag(vm-e2e): dump WaitForUACPrompt payload to share
claude May 15, 2026
4eaf261
test(vm-e2e): copy mcp_client.py from share each run
claude May 15, 2026
7de3e6f
fix(service): fall back to read-only desktop access when ALL_ACCESS f…
claude May 15, 2026
8737878
fix(service): open Winlogon by name in user-session worker
claude May 15, 2026
2450ef8
fix(service): spawn user-session worker directly on Winlogon for read…
claude May 15, 2026
2bf7d0a
revert(service): drop lpDesktop=winsta0\\winlogon attempt
claude May 15, 2026
0731d06
fix(service): broker hands worker a Winlogon desktop handle via stdin
claude May 17, 2026
a411ff3
fix(service): pass proc_handle directly to DuplicateHandle
claude May 17, 2026
68ffdcd
fix(service): force-replace PyInstaller bootloader manifest after build
claude May 17, 2026
acc01da
fix(service): preserve PyInstaller PKG overlay across manifest replace
claude May 17, 2026
6050a01
fix(service): delete existing manifest before adding ours
claude May 17, 2026
9a0018f
chore(gitignore): ignore d.bat dev helper
claude May 17, 2026
f128684
fix(service): set TokenUIAccess=1 on spawn token before CreateProcess…
claude May 17, 2026
66f728e
chore(gitignore): ignore q.bat dev helper
claude May 17, 2026
660f205
fix(service): enable SeTcbPrivilege before SetTokenInformation(TokenU…
claude May 18, 2026
d1d309e
fix(service): declare ctypes argtypes so OpenProcessToken accepts the…
claude May 18, 2026
3134e0e
fix(service): use win32api for manifest replace + verify uiAccess=true
claude May 18, 2026
5d98e46
fix(service): drop UpdateResource delete loop that returned ERROR_INT…
claude May 18, 2026
9c2d2b6
fix(service): declare argtypes on SetTokenInformation + GetTokenInfor…
claude May 18, 2026
2c1e255
fix(service): declare argtypes on user32 desktop+window-station APIs
claude May 18, 2026
199107c
diag(service): log OpenDesktopW failure at INFO so we can see the gle
claude May 18, 2026
6cdb38c
feat(service): broker enumerates consent.exe hwnd, worker walks via E…
claude May 18, 2026
e1465b0
diag(service): list all enumerated Winlogon windows so we can see why…
claude May 18, 2026
bc6de8b
fix(service): wait for consent.exe HWND before accepting worker tree
claude May 18, 2026
1a35d0a
fix(service): spawn user-session worker directly on Winlogon for read…
claude May 18, 2026
b638e5b
fix(service): impersonate user token around EnumDesktopWindows on Win…
claude May 18, 2026
563510e
feat(service): temporarily loosen Winlogon DACL so worker can attach
claude May 19, 2026
fe233c0
docs(secure-desktop): document Win11 Winlogon DACL limit hit by tree-…
claude May 19, 2026
92ccace
feat(service): try UIAccess+UIA-events path before DACL loosen
claude May 19, 2026
f7960d3
chore(gitignore): ignore additional vm_e2e dev helpers
claude May 19, 2026
f041312
feat(service): layer six cross-desktop probes + consent.exe identity …
claude May 25, 2026
a0f7f34
feat(service): add screenshot-based UAC fallback for Win11 hardening
claude May 25, 2026
614b5b4
build(uia-worker): keep PIL in the worker bundle for screenshot fallback
claude May 25, 2026
20165a7
fix(service): screenshot fallback runs in broker, not worker
claude May 25, 2026
6c9b45a
feat(uac): disable PromptOnSecureDesktop on install + detect consent.…
claude May 25, 2026
9919278
diag(uac): readback PromptOnSecureDesktop after write + log live value
claude May 25, 2026
3be88b6
fix(uac): also set ConsentPromptBehaviorAdmin=5 so secure-desktop dis…
claude May 25, 2026
e4186ea
docs(uac): record iter 7 finding -- Win 11 25H2 ignores dual-flag fix…
claude May 25, 2026
f00d03c
fix(uac): iter-8 -- try ConsentPromptBehaviorAdmin=4 instead of 5
claude May 25, 2026
a3ff126
test(uac): add iter-8 one-shot validator (CPB=4 in Dockur)
claude Jun 3, 2026
503fb64
chore(gitignore): ignore iter-8 trigger + result artifacts
claude Jun 3, 2026
bd67a1e
test(uac): kill straggler venv processes before uv sync in iter-8 val…
claude Jun 3, 2026
2d3f948
test(uac): widen consent.exe poll to 90s for KVM-disabled VMs
claude Jun 3, 2026
538237c
test(uac): fix desktop-name probe charset (was producing garbage)
claude Jun 3, 2026
09ae5d0
test(uac): add iter8-probe.ps1 -- fast UAC-trigger + desktop-probe only
claude Jun 3, 2026
d26a04d
docs(uac): iter-8 CPB=4 empirically confirmed on Win 11 25H2 Dockur
claude Jun 3, 2026
bcb048a
refactor(secure-desktop): delete UIA worker install pipeline (tier-1 …
claude Jun 4, 2026
57cc4b4
refactor(secure-desktop): delete Winlogon fallback paths (tier-2/3 cl…
claude Jun 4, 2026
0ebef5f
refactor(secure-desktop): drop now-orphan helpers (tier-4 cleanup)
claude Jun 4, 2026
0427650
docs(secure-desktop): trim iter-history prose from code docstrings
claude Jun 4, 2026
9af4cff
fix(desktop): remove stale is_secure_desktop_active call sites
claude Jun 4, 2026
96fff94
chore(gitignore): ignore fix-and-restart.bat VM helper
claude Jun 4, 2026
bf6f6e5
test(mcp_client): strip & accelerator when finding Yes/No buttons
claude Jun 4, 2026
13757f5
test(mcp_client): dump full UAC tree to share when Yes button not found
claude Jun 4, 2026
b4b2870
test(mcp_client): print UAC tree dump to stderr (Path write to UNC si…
claude Jun 4, 2026
bd02db8
fix(uac): poll until consent.exe dialog appears in UIA tree
claude Jun 4, 2026
0bab554
fix(uac): walk consent.exe via ElementFromHandle (cross-integrity)
claude Jun 4, 2026
1f44807
test(mcp_client): write timestamped UAC tree dumps + ship broker log
claude Jun 4, 2026
b3d109b
diag(uac): bake worker lookup outcome into tree _diag so client can s…
claude Jun 4, 2026
9d45563
chore(gitignore): exclude transient diagnostic bats + dump artifacts
claude Jun 4, 2026
50f8da3
diag(mcp_client): include first 25 node names in failure detail
claude Jun 4, 2026
c4a668f
fix(uac): walk every visible HWND owned by consent.exe, not just the …
claude Jun 4, 2026
f07a37b
fix(uac): enumerate consent.exe's child windows too, not just top-level
claude Jun 4, 2026
c73b654
fix(uac): FindAll(ProcessId=consent_pid) to reach UIA-only buttons
claude Jun 4, 2026
aa350fd
diag(mcp_client): also write per-run timestamped results-allow_all-HH…
claude Jun 4, 2026
a3051c7
diag(mcp_client): surface worker _diag on WaitForUACPrompt assertion too
claude Jun 4, 2026
6c81895
fix(uac): GetFocusedElement + walk-up to find consent.exe's button tree
claude Jun 4, 2026
8c5bbc8
fix(uac): strategy A -- capture consent.exe via UIA FocusChanged events
claude Jun 10, 2026
2302a24
chore(gitignore): untrack copylog.bat, ignore per-run e2e dumps
claude Jun 10, 2026
2fe03b4
fix(uac): correct SendInput INPUT struct size for x64 (cbSize must be…
claude Jun 10, 2026
a9fb40b
chore(gitignore): ignore diag.bat + diag-out.txt deploy artifacts
claude Jun 11, 2026
43dbcad
chore(gitignore): also ignore restart-host.bat
claude Jun 11, 2026
b71ecac
chore(gitignore): ignore copy-host-log.bat
claude Jun 11, 2026
05df3f9
chore(gitignore): ignore dump-host-log.bat
claude Jun 11, 2026
5799e3b
fix(uac): don't bail on transient Winlogon -- consent.exe check first
claude Jun 11, 2026
23310c2
fix(uac): probe Yes/No via ElementFromPoint when walker has no children
claude Jun 11, 2026
8a164ed
chore(lint): drop unused 'h' variable in point-probe helper
claude Jun 11, 2026
8f01bd4
Revert "chore(lint): drop unused 'h' variable in point-probe helper"
claude Jun 11, 2026
7a1915a
Revert "fix(uac): probe Yes/No via ElementFromPoint when walker has n…
claude Jun 11, 2026
872844e
fix(uac): synthesize Yes/No nodes from dialog bbox when XAML hidden f…
claude Jun 11, 2026
9cd21c8
fix(uac): correct synthesized Yes/No coords from observed Win 11 dial…
claude Jun 11, 2026
ee6b8ed
diag(uac): include synth-buttons outcome in node _diag
claude Jun 23, 2026
17c2523
fix(uac): route Click through UIAccess worker when target is consent.exe
claude Jun 23, 2026
ffa0264
fix(uac): SendInput mouse-click fallback in uia_click_at for XAML but…
claude Jul 6, 2026
3875ae0
chore(gitignore): ignore /run-setup.bat vm_e2e dev launcher
claude Jul 6, 2026
811052d
fix(uac): pipe DACL boot race -- fall back to INTERACTIVE, not Admini…
claude Jul 6, 2026
78e4668
test(vm_e2e): retry uv sync in setup.ps1 to survive proxy timeouts
claude Jul 6, 2026
bf2cdaf
test(vm_e2e): 300s server-wait + self-diagnostics on server-not-up
claude Jul 6, 2026
a8c9fad
test(vm_e2e): faster UAC trigger + longer WaitForUACPrompt wait
claude Jul 6, 2026
04a15a2
chore(gitignore): ignore uac-diag.txt / server-diag.txt diagnostic dumps
claude Jul 6, 2026
a119641
fix(uac): bound WaitForUACPrompt worker loop by deadline; readable ho…
claude Jul 6, 2026
bf2ef1b
fix(uac): synthesize Yes/No on every consent-window path, not just El…
claude Jul 6, 2026
5e2a8fc
chore(gitignore): ignore redeploy-worker.bat dev helper
claude Jul 6, 2026
3f0e651
chore(gitignore): ignore copy-hostlog.bat + installed-sha.txt dev art…
claude Jul 6, 2026
824fd7f
fix(uac): stop uia_get_tree timing out to an empty tree on slow VMs
claude Jul 6, 2026
fc73ce7
fix(uac): don't let ElementFromPoint type error skip the SendInput click
claude Jul 6, 2026
481cdf5
refactor(uac): remove dead speculative code from secure-desktop path
claude Jul 6, 2026
274a58a
test(vm_e2e): sync installed package from share before reboot-test
claude Jul 6, 2026
969369f
fix(uac): enforce consent policy on every host-routed click
claude Jul 7, 2026
936b228
chore(uac): drop test harness + docs from merge scope
claude Jul 7, 2026
13199e2
Merge pull request #1 from bensheed/claude/fix-windows-mcp-236-Ei1uJ
bensheed Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
458 changes: 366 additions & 92 deletions src/windows_mcp/__main__.py

Large diffs are not rendered by default.

66 changes: 0 additions & 66 deletions src/windows_mcp/desktop/screenshot.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import ctypes
import ctypes.wintypes
import io
import logging
import os

Expand Down Expand Up @@ -216,69 +213,6 @@ def capture(self, capture_rect: uia.Rect | None) -> Image.Image:
return image


# ---------------------------------------------------------------------------
# UAC / Secure Desktop detection (user-mode, no elevation needed)
# ---------------------------------------------------------------------------

_UOI_NAME = 2
_DESKTOP_READOBJECTS = 0x0001


def is_secure_desktop_active() -> bool:
"""Return True if the Secure Desktop (UAC prompt) is currently the input desktop.

Works from any privilege level — we only need DESKTOP_READOBJECTS to read
the desktop name. Returns False on any error so callers degrade safely.
"""
try:
hdesk = ctypes.windll.user32.OpenInputDesktop(0, False, _DESKTOP_READOBJECTS)
if not hdesk:
return False
try:
buf = ctypes.create_unicode_buffer(256)
needed = ctypes.wintypes.DWORD()
ctypes.windll.user32.GetUserObjectInformationW(
hdesk, _UOI_NAME, buf, ctypes.sizeof(buf), ctypes.byref(needed)
)
return buf.value.lower() == "winlogon"
finally:
ctypes.windll.user32.CloseDesktop(hdesk)
except Exception:
return False


# ---------------------------------------------------------------------------
# Service backend (routes through the LocalSystem host service when installed)
# ---------------------------------------------------------------------------


class _ServiceBackend(_ScreenshotBackend):
"""Capture via the Windows MCP host service.

Used automatically when the service is installed AND the Secure Desktop is
active (UAC prompt). During normal desktop use this backend is skipped so
there is zero pipe overhead on every screenshot.
"""

name = "service"
priority = 5 # lower number = tried first; beats dxcam (10), mss (20), pillow (100)

def is_available(self, capture_rect: "uia.Rect | None") -> bool:
if not is_secure_desktop_active():
return False
try:
from windows_mcp.service.pipe import get_client
return get_client().is_available()
except Exception:
return False

def capture(self, capture_rect: "uia.Rect | None") -> Image.Image:
from windows_mcp.service.pipe import get_client
png_bytes = get_client().screenshot()
img = Image.open(io.BytesIO(png_bytes))
return _crop_screenshot(img, capture_rect)


# ---------------------------------------------------------------------------
# Instance management
# ---------------------------------------------------------------------------
Expand Down
131 changes: 61 additions & 70 deletions src/windows_mcp/desktop/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,50 @@
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


def _is_consent_target(x: int, y: int) -> bool:
"""Return True if the screen pixel at (x, y) is owned by consent.exe.

Used to detect when a Click should be routed through the SYSTEM host
service's UIAccess worker instead of the broker's medium-integrity
uia.Click, which UIPI blocks against the UAC dialog.
"""
try:
hwnd = win32gui.WindowFromPoint((x, y))
if not hwnd:
return False
# WindowFromPoint may return a child HWND; walk up to the top.
top = win32gui.GetAncestor(hwnd, win32con.GA_ROOT)
_, pid = win32process.GetWindowThreadProcessId(top or hwnd)
if not pid:
return False
try:
name = Process(pid).name().lower()
except Exception: # noqa: BLE001
return False
return name == "consent.exe"
except Exception: # noqa: BLE001
return False


def _route_click_through_host(x: int, y: int) -> bool:
"""Best-effort click via the SYSTEM host service. Returns True if it took.

Lazy-imports the pipe client so non-UAC code paths don't pay the cost.
"""
try:
from windows_mcp.service.pipe import get_client
client = get_client()
if not client.is_available():
return False
ok = bool(client.uia_click_at(x, y))
if ok:
logger.info("Click(%d,%d) routed through host UIAccess worker", x, y)
return ok
except Exception as exc: # noqa: BLE001
logger.warning("host-routed click failed, falling back to local: %s", exc)
return False

import windows_mcp.uia as uia # noqa: E402

# Key name aliases for shortcut keys that differ from UIA SpecialKeyNames
Expand Down Expand Up @@ -121,15 +165,11 @@ def get_state(
capture_rect = self.get_display_union_rect(display_indices) if display_indices else None
screenshot_region = self._rect_to_bounding_box(capture_rect) if capture_rect else None

# Fast path for Screenshot tool (use_ui_tree=False): skip window enumeration.
# UIAutomation calls (get_controls_handles / get_windows / get_active_window)
# can hang when an app is launching and not responding to WM messages.
# Also skip when the Secure Desktop is active (UAC prompt): UIA cannot
# cross the Winlogon desktop boundary from user mode, so all calls
# return None and crash attempting to walk the accessibility tree.
from windows_mcp.desktop.screenshot import is_secure_desktop_active
uac_active = is_secure_desktop_active()
if use_ui_tree and not uac_active:
# Fast path for Screenshot tool (use_ui_tree=False): skip window
# enumeration. UIAutomation calls (get_controls_handles / get_windows
# / get_active_window) can hang when an app is launching and not
# responding to WM messages.
if use_ui_tree:
controls_handles = self.get_controls_handles() # Taskbar,Program Manager,Apps, Dialogs
windows, windows_handles = self.get_windows(controls_handles=controls_handles) # Apps
active_window = self.get_active_window(windows=windows) # Active Window
Expand Down Expand Up @@ -163,10 +203,7 @@ def get_state(
logger.debug(f"Active window: {active_window or 'No Active Window Found'}")
logger.debug(f"Windows: {windows}")

if uac_active and use_ui_tree:
# UIA tree via the host service — the only way to read the UAC dialog.
tree_state = self._build_uac_tree_state()
elif use_ui_tree:
if use_ui_tree:
other_windows_handles = list(controls_handles - windows_handles)
tree_state = self.tree.get_state(
active_window_handle, other_windows_handles, use_dom=use_dom
Expand Down Expand Up @@ -647,23 +684,17 @@ def click(self, loc: tuple[int, int] | list[int], button: str = "left", clicks:
else:
x, y = loc

# With PromptOnSecureDesktop disabled (set by `windows-mcp service install`),
# UAC prompts appear on the Default desktop and uia.Click() reaches them via
# SendInput — hardware-level input that bypasses UIPI.
# The service route below is a fallback for the rare case where the policy
# wasn't applied (secure desktop still active).
from windows_mcp.desktop.screenshot import is_secure_desktop_active
if button == "left" and is_secure_desktop_active():
from windows_mcp.service import get_host_client
try:
get_host_client().uia_click_at(x, y)
except Exception as exc:
logger.warning("UAC click via service failed: %s", exc)
return

if clicks == 0:
uia.SetCursorPos(x, y)
return
# UAC routing: consent.exe runs at System integrity. UIPI blocks
# mouse input from medium-integrity processes, so a normal uia.Click
# at the dialog's Yes/No coords silently fails to dismiss UAC. The
# SYSTEM host service has a UIAccess-tokened user-session worker
# that CAN invoke across integrity; route the click there if the
# target pixel is owned by consent.exe.
if _is_consent_target(x, y) and _route_click_through_host(x, y):
return
Comment on lines +690 to +697

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

5. Policy denial masked click 🐞 Bug ≡ Correctness

Desktop.click() routes consent.exe clicks through the host service but suppresses host errors
(including "policy denied") and then falls back to a local click, while the Click tool always
returns a success message. This hides policy enforcement from callers and can make agents believe a
UAC prompt was dismissed when it was not.
Agent Prompt
## Issue description
UAC clicks that are routed through the host service can be denied by policy (host returns an error), but `_route_click_through_host()` catches the exception and returns `False`, causing `Desktop.click()` to fall back to a local `uia.Click()` and the `Click` tool to still report success. This breaks the contract described in the PR ("refused with a policy denied error") and makes automation unreliable.

## Issue Context
- Host service enforces consent policy and returns an error response on denial.
- Pipe client converts `Response.error` into a raised exception.
- Desktop click routing currently swallows that exception and falls back.

## Fix Focus Areas
- src/windows_mcp/desktop/service.py[42-83]
- src/windows_mcp/desktop/service.py[681-712]
- src/windows_mcp/tools/input.py[39-57]
- src/windows_mcp/service/pipe.py[90-118]
- src/windows_mcp/service/host.py[283-290]

## Recommended change
- In `_route_click_through_host`, do **not** swallow `RuntimeError` that indicates a policy denial (e.g., message contains `"policy denied"`); instead raise a clear exception.
- In `Desktop.click`, if `_is_consent_target(x,y)` is true and host routing fails due to policy denial, abort the click (don’t fall back to local) so the tool call surfaces the failure.
- Optionally adjust `Click` tool to return/raise an error when the underlying click was blocked/failed for consent.exe targets.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

match button:
case "left":
if clicks >= 2:
Expand All @@ -688,6 +719,7 @@ def type(
press_enter: bool | str = False,
):
x, y = loc

uia.Click(x, y)
if caret_position == "start":
uia.SendKeys("{Home}", waitTime=0.05)
Expand Down Expand Up @@ -738,11 +770,12 @@ def scroll(
return 'Invalid type. Use "horizontal" or "vertical".'
return None

def drag(self, loc: tuple[int, int]|list[int]):
def drag(self, loc: tuple[int, int] | list[int]):
if isinstance(loc, list):
x, y = loc[0], loc[1]
else:
x, y = loc

sleep(0.5)
cx, cy = uia.GetCursorPos()
uia.DragDrop(cx, cy, x, y, moveSpeed=1)
Expand Down Expand Up @@ -847,48 +880,6 @@ def callback(hwnd, _):
handles.add(secondary_taskbar_hwnd)
return handles

def _build_uac_tree_state(self) -> TreeState:
"""Build a TreeState from the host service UIA tree (used during UAC)."""
from windows_mcp.service import get_host_client
try:
raw = get_host_client().uia_tree()
except Exception as exc:
logger.warning("_build_uac_tree_state: service call failed: %s", exc)
return TreeState(status=False)

interactive: list[TreeElementNode] = []

def _walk(node: dict, window_name: str) -> None:
bbox = node.get("bbox", {})
center = node.get("center", {})
ctrl = node.get("control_type", "")
name = node.get("name", "")
can_invoke = node.get("can_invoke", False)

# Include buttons and any invokable element as interactive nodes.
if bbox and center and (can_invoke or ctrl.lower() in ("button",)):
bb = BoundingBox(
left=bbox["left"], top=bbox["top"],
right=bbox["right"], bottom=bbox["bottom"],
width=bbox["width"], height=bbox["height"],
)
c = Center(x=center["x"], y=center["y"])
interactive.append(TreeElementNode(
name=name,
control_type=ctrl + "Control" if ctrl else "Control",
window_name=window_name,
bounding_box=bb,
center=c,
metadata={},
))
for child in node.get("children", []):
_walk(child, window_name)

for top in raw:
_walk(top, top.get("name", "Secure Desktop"))

return TreeState(status=True, interactive_nodes=interactive)

def get_active_window(self, windows: list[Window] | None = None) -> Window | None:
try:
if windows is None:
Expand Down
4 changes: 4 additions & 0 deletions src/windows_mcp/infrastructure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
ServerConfig,
SecurityConfig,
ToolsConfig,
SecureDesktopConfig,
SECURE_DESKTOP_POLICIES,
CONFIG_DIR,
CONFIG_FILE,
discover_config_path,
Expand All @@ -34,6 +36,8 @@
"ServerConfig",
"SecurityConfig",
"ToolsConfig",
"SecureDesktopConfig",
"SECURE_DESKTOP_POLICIES",
"CONFIG_DIR",
"CONFIG_FILE",
"discover_config_path",
Expand Down
45 changes: 45 additions & 0 deletions src/windows_mcp/infrastructure/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,33 @@ class ToolsConfig:
exclude: list[str] = field(default_factory=list)


SECURE_DESKTOP_POLICIES = ("block", "allow_with_match", "allow_all")


@dataclass
class SecureDesktopConfig:
"""Policy for how the LocalSystem host service may handle UAC consent prompts.

``policy`` values:
- ``block`` — service exposes the dialog to the agent but
REFUSES to auto-click Yes/No. Human approval
still required. (default)
- ``allow_with_match``— auto-click only if the requesting binary's
publisher (CommonName from the Authenticode
signature) matches one of ``publishers_allowlist``.
- ``allow_all`` — auto-click any UAC prompt. Only safe in sandboxed
VMs. Opt-in.
"""
policy: str = "block"
publishers_allowlist: list[str] = field(default_factory=list)


@dataclass
class WindowsMCPConfig:
server: ServerConfig = field(default_factory=ServerConfig)
security: SecurityConfig = field(default_factory=SecurityConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
secure_desktop: SecureDesktopConfig = field(default_factory=SecureDesktopConfig)
source_path: Path | None = None


Expand Down Expand Up @@ -117,6 +139,19 @@ def load_config(path: Path | None) -> WindowsMCPConfig:
if "exclude" in tools:
cfg.tools.exclude = _list_of_strings(tools["exclude"], "tools.exclude")

secure_desktop = data.get("secure_desktop", {})
if "policy" in secure_desktop:
p = str(secure_desktop["policy"])
if p not in SECURE_DESKTOP_POLICIES:
raise ValueError(
f"secure_desktop.policy must be one of {SECURE_DESKTOP_POLICIES}, got {p!r}"
)
cfg.secure_desktop.policy = p
if "publishers_allowlist" in secure_desktop:
cfg.secure_desktop.publishers_allowlist = _list_of_strings(
secure_desktop["publishers_allowlist"], "secure_desktop.publishers_allowlist"
)

cfg.source_path = path
return cfg

Expand Down Expand Up @@ -160,4 +195,14 @@ def write_config(cfg: WindowsMCPConfig, path: Path) -> None:
items = ', '.join(f'"{t}"' for t in cfg.tools.exclude)
lines += ['[tools]', f'exclude = [{items}]', '']

sd_cfg, sd_def = cfg.secure_desktop, SecureDesktopConfig()
sd_lines: list[str] = []
if sd_cfg.policy != sd_def.policy:
sd_lines.append(f'policy = "{sd_cfg.policy}"')
if sd_cfg.publishers_allowlist:
items = ', '.join(f'"{p}"' for p in sd_cfg.publishers_allowlist)
sd_lines.append(f'publishers_allowlist = [{items}]')
if sd_lines:
lines += ['[secure_desktop]'] + sd_lines + ['']

path.write_text('\n'.join(lines), encoding='utf-8')
Loading