From c090b005090d99d165d32d1245e4423d5f17750f Mon Sep 17 00:00:00 2001 From: Daniel Otykier Date: Wed, 19 Aug 2026 10:12:24 +0200 Subject: [PATCH 1/2] Add optional app allowlist restricting which apps can be automated By default the server can automate any application on the desktop - File Explorer, browsers, terminals - which is a large attack surface if the driving agent is misled (e.g. by prompt injection). Setting FLAUI_MCP_ALLOWED_APPS (semicolon/comma-separated process names, case- insensitive, ".exe" optional) restricts automation to the listed apps; unset preserves the previous allow-everything behavior. Enforcement points: - windows_launch refuses non-allowed executables. - Every window-handle registration in SessionManager verifies the owning process, so refs and handles - and with them all ref-based tools - can only point at allowed apps. windows_list_windows lists only allowed apps' windows and registers no handles for others. - Ref-less keyboard input (send_keys/type/batch type without ref) checks that the foreground window belongs to an allowed process, and the Windows key is rejected outright since it opens system UI (Start, Win+R) outside any allowlist. - windows_screenshot refuses fullScreen capture and foreground capture of non-allowed apps. Matching is by process name: this guards against a misdirected agent driving unintended apps through this server, not against a local attacker who can rename executables, and it does not constrain other tools the agent may hold. In-process dialogs of allowed apps (including common file dialogs) keep working. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + README.md | 48 +++++++ src/FlaUI.Mcp/Core/ProcessPolicy.cs | 148 ++++++++++++++++++++ src/FlaUI.Mcp/Core/SessionManager.cs | 50 ++++++- src/FlaUI.Mcp/Core/Win32Desktop.cs | 18 +++ src/FlaUI.Mcp/Program.cs | 15 +- src/FlaUI.Mcp/Tools/BatchTool.cs | 14 +- src/FlaUI.Mcp/Tools/ScreenshotTool.cs | 25 +++- src/FlaUI.Mcp/Tools/SendKeysTool.cs | 43 +++++- src/FlaUI.Mcp/Tools/TypeTools.cs | 14 +- tests/FlaUI.Mcp.Tests/ProcessPolicyTests.cs | 142 +++++++++++++++++++ 11 files changed, 503 insertions(+), 15 deletions(-) create mode 100644 src/FlaUI.Mcp/Core/ProcessPolicy.cs create mode 100644 tests/FlaUI.Mcp.Tests/ProcessPolicyTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 806cd66..e656050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Optional app allowlist via the `FLAUI_MCP_ALLOWED_APPS` environment variable (semicolon- or comma-separated process names). When set, the server refuses to launch, list, snapshot, screenshot or send input to any other application: window handles are only issued for allowed processes (scoping every ref-based tool), ref-less keyboard input requires an allowed foreground window and rejects the Windows key, and `fullScreen` screenshots are disabled. Unset means everything is allowed, as before. - The server now keeps the display awake while tools are actively being called, so Windows does not turn off the screen or show the lock screen in the middle of a long automation run. Implemented with a Windows power availability request (`PowerCreateRequest`/`PowerSetRequest` with `PowerRequestDisplayRequired` + `PowerRequestSystemRequired`) — the same mechanism video players and conferencing apps use, visible in `powercfg /requests`. The request is released after 5 minutes without a tool call; configure the idle period (or disable with `0`) via the `FLAUI_MCP_KEEP_AWAKE_SECONDS` environment variable. ### Fixed diff --git a/README.md b/README.md index 678709e..8582f3c 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,54 @@ sleep). A domain group policy that enforces a hard machine inactivity limit and is not suppressed by availability requests — no application can override that policy. +### Restricting Which Apps Can Be Automated + +By default, FlaUI-MCP can automate **any** application on the desktop — including +File Explorer, browsers, and terminals. If the agent driving it is ever misled +(for example by prompt injection), that is a large attack surface. Set the +`FLAUI_MCP_ALLOWED_APPS` environment variable to restrict automation to specific +apps: + +```json +{ + "mcpServers": { + "windows": { + "type": "local", + "command": "C:\\path\\to\\FlaUI.Mcp.exe", + "env": { + "FLAUI_MCP_ALLOWED_APPS": "TabularEditor3" + } + } + } +} +``` + +The value is a semicolon- or comma-separated list of process names, matched +case-insensitively, with or without `.exe` (full paths are reduced to their +file name). When the variable is unset or empty, everything is allowed. + +While the allowlist is active: + +- `windows_launch` refuses to start non-allowed executables. +- Window handles are only ever issued for allowed processes, so every ref-based + tool (snapshot, click, type, fill, get_text, screenshot by handle/ref) is + automatically scoped to allowed apps. `windows_list_windows` lists only + allowed apps' windows. +- Ref-less keyboard input (`windows_send_keys` / `windows_type` without a ref) + verifies the **foreground window** belongs to an allowed process first, and + the Windows key is rejected outright (it opens system UI like the Start menu + and Win+R outside any allowlist). +- `windows_screenshot` refuses `fullScreen` capture and foreground-window + capture of non-allowed apps, so other windows' content is not disclosed. + +Scope honestly stated: matching is by process name, so this is a guard against +a misdirected or prompt-injected agent driving unintended apps *through this +server* — not a sandbox against a local attacker, and it does not restrict +anything the agent can do through other tools (like a shell). Dialogs owned by +the allowed process (including common file dialogs, which run in-process) keep +working; apps it launches as separate processes (e.g. a browser for OAuth) are +blocked unless also listed. + ### Tool Examples Send a keyboard chord to a target element: diff --git a/src/FlaUI.Mcp/Core/ProcessPolicy.cs b/src/FlaUI.Mcp/Core/ProcessPolicy.cs new file mode 100644 index 0000000..23cfdc2 --- /dev/null +++ b/src/FlaUI.Mcp/Core/ProcessPolicy.cs @@ -0,0 +1,148 @@ +namespace PlaywrightWindows.Mcp.Core; + +/// +/// Optional allowlist restricting which applications the MCP tools may automate. +/// +/// Configured via the FLAUI_MCP_ALLOWED_APPS environment variable: a semicolon- +/// or comma-separated list of process names, matched case-insensitively and with +/// or without an ".exe" suffix (full paths are reduced to their file name). +/// When the variable is unset or empty, everything is allowed (the historical +/// behavior). When set, the server refuses to launch, register windows for, +/// screenshot, or send input to any process not on the list. +/// +/// Matching is by process name, so this is a guard against a misdirected or +/// prompt-injected agent driving unintended apps through this server - not a +/// sandbox against a local attacker who can rename executables. +/// +public sealed class ProcessPolicy +{ + public const string EnvironmentVariable = "FLAUI_MCP_ALLOWED_APPS"; + + private readonly HashSet _allowedNames; + + /// A policy that allows every application (no allowlist configured). + public static ProcessPolicy AllowAll { get; } = new(Array.Empty()); + + public ProcessPolicy(IEnumerable allowedApps) + { + _allowedNames = allowedApps + .Select(Normalize) + .Where(name => name.Length > 0) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + public static ProcessPolicy FromEnvironment() + { + var raw = Environment.GetEnvironmentVariable(EnvironmentVariable); + if (string.IsNullOrWhiteSpace(raw)) + { + return AllowAll; + } + + return new ProcessPolicy(raw.Split( + new[] { ';', ',' }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + /// True when an allowlist is configured; false means allow everything. + public bool IsRestricted => _allowedNames.Count > 0; + + public bool IsNameAllowed(string? processName) + { + if (!IsRestricted) + { + return true; + } + if (string.IsNullOrWhiteSpace(processName)) + { + return false; + } + return _allowedNames.Contains(Normalize(processName)); + } + + /// + /// Check a process by id. When restricted, an unknown or exited process is denied. + /// + public bool IsProcessAllowed(int processId) + { + if (!IsRestricted) + { + return true; + } + return IsNameAllowed(TryGetProcessName(processId)); + } + + /// + /// Check the executable path or name a launch request points at. + /// + public bool IsExecutableAllowed(string appPath) + { + if (!IsRestricted) + { + return true; + } + return IsNameAllowed(appPath); + } + + /// + /// Verify the current foreground window's process against the allowlist. + /// Returns null when allowed (or unrestricted), otherwise an error message. + /// Used to gate ref-less keyboard input, which goes to whatever has focus. + /// + public string? CheckForegroundWindowAllowed() + { + if (!IsRestricted) + { + return null; + } + + var processId = Win32Desktop.GetForegroundWindowProcessId(); + if (IsProcessAllowed(processId)) + { + return null; + } + + var name = TryGetProcessName(processId) ?? "unknown"; + return DescribeDenied($"The foreground window's process '{name}'") + + " Focus an allowed window first, or target an element ref directly."; + } + + public string DescribeDenied(string subject) + { + return $"{subject} is not in the FlaUI-MCP app allowlist. " + + $"Only these apps can be automated: {string.Join(", ", _allowedNames.OrderBy(n => n, StringComparer.OrdinalIgnoreCase))} " + + $"(configured via the {EnvironmentVariable} environment variable)."; + } + + public static string? TryGetProcessName(int processId) + { + if (processId <= 0) + { + return null; + } + try + { + return System.Diagnostics.Process.GetProcessById(processId).ProcessName; + } + catch + { + return null; + } + } + + private static string Normalize(string name) + { + var trimmed = name.Trim().Trim('"'); + try + { + trimmed = Path.GetFileName(trimmed); + } + catch { /* keep the raw value if the entry is not path-like */ } + + if (trimmed.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^4]; + } + return trimmed; + } +} diff --git a/src/FlaUI.Mcp/Core/SessionManager.cs b/src/FlaUI.Mcp/Core/SessionManager.cs index 989ac4d..262d308 100644 --- a/src/FlaUI.Mcp/Core/SessionManager.cs +++ b/src/FlaUI.Mcp/Core/SessionManager.cs @@ -16,17 +16,24 @@ public class SessionManager : IDisposable private readonly Dictionary _windowHwnds = new(); private readonly Dictionary _windowPids = new(); private readonly Dictionary _hwndToHandle = new(); + private readonly ProcessPolicy _processPolicy; private int _windowCounter = 0; - public SessionManager() + public SessionManager(ProcessPolicy? processPolicy = null) { _automation = new UIA3Automation(); + _processPolicy = processPolicy ?? ProcessPolicy.AllowAll; } public UIA3Automation Automation => _automation; public (string handle, Window window) LaunchApp(string appPath, string[]? args = null) { + if (!_processPolicy.IsExecutableAllowed(appPath)) + { + throw new Exception(_processPolicy.DescribeDenied($"'{appPath}'")); + } + // Use Process.Start for more reliable launching var psi = new System.Diagnostics.ProcessStartInfo { @@ -130,6 +137,8 @@ public string RegisterWindow(Window window) } catch { /* best effort */ } + EnsureProcessAllowed(pid); + if (hwnd != 0 && _hwndToHandle.TryGetValue(hwnd, out var existing)) { _windows[existing] = window; @@ -156,6 +165,8 @@ public string RegisterWindow(Window window) /// public string RegisterNativeWindow(nint hwnd, int processId) { + EnsureProcessAllowed(processId); + if (_hwndToHandle.TryGetValue(hwnd, out var existing)) { _windowPids[existing] = processId; @@ -223,15 +234,16 @@ public nint GetWindowHwnd(string handle) continue; } - var handle = RegisterNativeWindow(info.Hwnd, info.ProcessId); + var processName = ProcessPolicy.TryGetProcessName(info.ProcessId); - string? processName = null; - try + // When an allowlist is active, windows of other apps are not listed + // at all - no handle is registered, so they stay unreachable. + if (!_processPolicy.IsNameAllowed(processName)) { - processName = System.Diagnostics.Process.GetProcessById(info.ProcessId).ProcessName; + continue; } - catch { } + var handle = RegisterNativeWindow(info.Hwnd, info.ProcessId); result.Add((handle, info.Title, processName)); } return result; @@ -281,6 +293,32 @@ public void CloseWindow(string handle) _windowPids.Remove(handle); } + /// + /// Throw when an app allowlist is active and the process is not on it (or + /// cannot be identified). Every window-handle registration funnels through + /// this, so refs and handles can only ever point at allowed apps. + /// + private void EnsureProcessAllowed(int processId) + { + if (!_processPolicy.IsRestricted) + { + return; + } + + if (processId == 0) + { + throw new Exception( + "Cannot verify this window's owning process against the app allowlist " + + $"({ProcessPolicy.EnvironmentVariable}), so it is not controllable."); + } + + if (!_processPolicy.IsProcessAllowed(processId)) + { + var name = ProcessPolicy.TryGetProcessName(processId) ?? $"pid {processId}"; + throw new Exception(_processPolicy.DescribeDenied($"Process '{name}'")); + } + } + public void Dispose() { foreach (var app in _applications.Values) diff --git a/src/FlaUI.Mcp/Core/Win32Desktop.cs b/src/FlaUI.Mcp/Core/Win32Desktop.cs index 0b643b4..6b9dbc4 100644 --- a/src/FlaUI.Mcp/Core/Win32Desktop.cs +++ b/src/FlaUI.Mcp/Core/Win32Desktop.cs @@ -70,6 +70,9 @@ public static class Win32Desktop [DllImport("user32.dll")] private static extern bool GetWindowRect(nint hWnd, out RECT lpRect); + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + [DllImport("dwmapi.dll")] private static extern int DwmGetWindowAttribute(nint hWnd, int dwAttribute, out int pvAttribute, int cbAttribute); @@ -140,6 +143,21 @@ public static void CloseWindow(nint hwnd) PostMessage(hwnd, WM_CLOSE, 0, 0); } + /// + /// Get the process id owning the current foreground window, or 0 if there + /// is no foreground window. + /// + public static int GetForegroundWindowProcessId() + { + var hwnd = GetForegroundWindow(); + if (hwnd == 0) + { + return 0; + } + GetWindowThreadProcessId(hwnd, out var pid); + return (int)pid; + } + /// /// Get a window's bounding rectangle in screen coordinates, or null on failure. /// diff --git a/src/FlaUI.Mcp/Program.cs b/src/FlaUI.Mcp/Program.cs index 10662b2..562c00c 100644 --- a/src/FlaUI.Mcp/Program.cs +++ b/src/FlaUI.Mcp/Program.cs @@ -4,8 +4,13 @@ DpiUtility.EnablePerMonitorV2(); +// Optional app allowlist: when FLAUI_MCP_ALLOWED_APPS is set (semicolon- or +// comma-separated process names, e.g. "TabularEditor3;notepad"), only those +// apps can be launched, listed, snapshotted, screenshotted or receive input. +var processPolicy = ProcessPolicy.FromEnvironment(); + // Create shared services -var sessionManager = new SessionManager(); +var sessionManager = new SessionManager(processPolicy); var elementRegistry = new ElementRegistry(); var invokeTracker = new PendingInvokeTracker(); @@ -30,15 +35,15 @@ toolRegistry.RegisterTool(new LaunchTool(sessionManager)); toolRegistry.RegisterTool(new SnapshotTool(sessionManager, elementRegistry, invokeTracker)); toolRegistry.RegisterTool(new ClickTool(elementRegistry, invokeTracker)); -toolRegistry.RegisterTool(new TypeTool(elementRegistry, invokeTracker)); +toolRegistry.RegisterTool(new TypeTool(elementRegistry, invokeTracker, processPolicy)); toolRegistry.RegisterTool(new FillTool(elementRegistry, invokeTracker)); toolRegistry.RegisterTool(new GetTextTool(elementRegistry, invokeTracker)); -toolRegistry.RegisterTool(new SendKeysTool(elementRegistry, invokeTracker)); -toolRegistry.RegisterTool(new ScreenshotTool(sessionManager, elementRegistry, invokeTracker)); +toolRegistry.RegisterTool(new SendKeysTool(elementRegistry, invokeTracker, processPolicy)); +toolRegistry.RegisterTool(new ScreenshotTool(sessionManager, elementRegistry, invokeTracker, processPolicy)); toolRegistry.RegisterTool(new ListWindowsTool(sessionManager)); toolRegistry.RegisterTool(new FocusWindowTool(sessionManager)); toolRegistry.RegisterTool(new CloseWindowTool(sessionManager)); -toolRegistry.RegisterTool(new BatchTool(sessionManager, elementRegistry, invokeTracker)); +toolRegistry.RegisterTool(new BatchTool(sessionManager, elementRegistry, invokeTracker, processPolicy)); // Create and run MCP server var server = new McpServer(toolRegistry); diff --git a/src/FlaUI.Mcp/Tools/BatchTool.cs b/src/FlaUI.Mcp/Tools/BatchTool.cs index c95613e..df291d4 100644 --- a/src/FlaUI.Mcp/Tools/BatchTool.cs +++ b/src/FlaUI.Mcp/Tools/BatchTool.cs @@ -15,13 +15,15 @@ public class BatchTool : ToolBase private readonly ElementRegistry _elementRegistry; private readonly SnapshotBuilder _snapshotBuilder; private readonly PendingInvokeTracker _invokeTracker; + private readonly ProcessPolicy _processPolicy; - public BatchTool(SessionManager sessionManager, ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null) + public BatchTool(SessionManager sessionManager, ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null, ProcessPolicy? processPolicy = null) { _sessionManager = sessionManager; _elementRegistry = elementRegistry; _snapshotBuilder = new SnapshotBuilder(elementRegistry); _invokeTracker = invokeTracker ?? new PendingInvokeTracker(); + _processPolicy = processPolicy ?? ProcessPolicy.AllowAll; } public override string Name => "windows_batch"; @@ -222,6 +224,16 @@ private string ExecuteType(JsonElement action) element.Focus(); Thread.Sleep(30); } + else + { + // Ref-less input goes to whatever has keyboard focus, so verify + // the foreground window belongs to an allowed app. + var denied = _processPolicy.CheckForegroundWindowAllowed(); + if (denied != null) + { + return denied; + } + } Keyboard.Type(text); return $"Typed \"{text}\""; diff --git a/src/FlaUI.Mcp/Tools/ScreenshotTool.cs b/src/FlaUI.Mcp/Tools/ScreenshotTool.cs index 5a797d9..8b417fe 100644 --- a/src/FlaUI.Mcp/Tools/ScreenshotTool.cs +++ b/src/FlaUI.Mcp/Tools/ScreenshotTool.cs @@ -12,12 +12,14 @@ public class ScreenshotTool : ToolBase private readonly SessionManager _sessionManager; private readonly ElementRegistry _elementRegistry; private readonly PendingInvokeTracker _invokeTracker; + private readonly ProcessPolicy _processPolicy; - public ScreenshotTool(SessionManager sessionManager, ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null) + public ScreenshotTool(SessionManager sessionManager, ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null, ProcessPolicy? processPolicy = null) { _sessionManager = sessionManager; _elementRegistry = elementRegistry; _invokeTracker = invokeTracker ?? new PendingInvokeTracker(); + _processPolicy = processPolicy ?? ProcessPolicy.AllowAll; } public override string Name => "windows_screenshot"; @@ -88,6 +90,14 @@ public override Task ExecuteAsync(JsonElement? arguments) if (fullScreen) { + // A full-screen capture would include windows of apps outside + // the allowlist, so it is disabled while one is active. + if (_processPolicy.IsRestricted) + { + return Task.FromResult(ErrorResult( + "fullScreen capture is disabled while the app allowlist " + + $"({ProcessPolicy.EnvironmentVariable}) is active. Capture an allowed window by handle instead.")); + } capture = Capture.Screen(); } else if (!string.IsNullOrEmpty(refId)) @@ -144,6 +154,19 @@ public override Task ExecuteAsync(JsonElement? arguments) } else { + // Capturing the foreground window: verify it belongs to an + // allowed app before touching it. + if (_processPolicy.IsRestricted) + { + var foregroundPid = Win32Desktop.GetForegroundWindowProcessId(); + if (!_processPolicy.IsProcessAllowed(foregroundPid)) + { + var name = ProcessPolicy.TryGetProcessName(foregroundPid) ?? "unknown"; + return Task.FromResult(ErrorResult( + _processPolicy.DescribeDenied($"The foreground window's process '{name}'"))); + } + } + // Capture foreground window var focusedElement = _sessionManager.Automation.FocusedElement(); if (focusedElement == null) diff --git a/src/FlaUI.Mcp/Tools/SendKeysTool.cs b/src/FlaUI.Mcp/Tools/SendKeysTool.cs index df8d459..9739916 100644 --- a/src/FlaUI.Mcp/Tools/SendKeysTool.cs +++ b/src/FlaUI.Mcp/Tools/SendKeysTool.cs @@ -116,16 +116,19 @@ public class SendKeysTool : ToolBase private readonly ElementRegistry _elementRegistry; private readonly PendingInvokeTracker _invokeTracker; + private readonly ProcessPolicy _processPolicy; /// /// Initializes a new instance of the class. /// /// Registry used to resolve element references for focus targeting. /// Tracker used to fail fast when the target app's UIA provider is blocked. - public SendKeysTool(ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null) + /// Optional allowlist restricting which apps may receive input. + public SendKeysTool(ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null, ProcessPolicy? processPolicy = null) { _elementRegistry = elementRegistry; _invokeTracker = invokeTracker ?? new PendingInvokeTracker(); + _processPolicy = processPolicy ?? ProcessPolicy.AllowAll; } /// @@ -214,6 +217,16 @@ public override Task ExecuteAsync(JsonElement? arguments) element.Focus(); Thread.Sleep(50); } + else + { + // Ref-less input goes to whatever has keyboard focus, so verify + // the foreground window belongs to an allowed app. + var denied = _processPolicy.CheckForegroundWindowAllowed(); + if (denied != null) + { + return Task.FromResult(ErrorResult(denied)); + } + } if (hasChord) { @@ -229,6 +242,12 @@ public override Task ExecuteAsync(JsonElement? arguments) return Task.FromResult(ErrorResult(chordError)); } + var chordDenied = CheckKeysAllowed(chordKeys); + if (chordDenied != null) + { + return Task.FromResult(ErrorResult(chordDenied)); + } + PressKeys(chordKeys); var targetForChord = string.IsNullOrWhiteSpace(refId) ? "focused element" : refId; @@ -251,6 +270,12 @@ public override Task ExecuteAsync(JsonElement? arguments) return Task.FromResult(ErrorResult(stepError)); } + var stepDenied = CheckKeysAllowed(stepKeys); + if (stepDenied != null) + { + return Task.FromResult(ErrorResult(stepDenied)); + } + PressKeys(stepKeys); actions.Add(string.Join("+", stepTokens)); Thread.Sleep(30); @@ -265,6 +290,22 @@ public override Task ExecuteAsync(JsonElement? arguments) } } + /// + /// The Windows key opens UI (Start menu, Win+R, Win+E, ...) that always + /// belongs to processes outside any allowlist, so it is rejected while an + /// allowlist is active. + /// + private string? CheckKeysAllowed(List keys) + { + if (_processPolicy.IsRestricted && + keys.Any(k => k is VirtualKeyShort.LWIN or VirtualKeyShort.RWIN)) + { + return "The Windows key is disabled while the app allowlist is active, " + + "because it opens system UI outside the allowed apps."; + } + return null; + } + private static List TryResolveKeys(List tokens, out string? error) { var keys = new List(); diff --git a/src/FlaUI.Mcp/Tools/TypeTools.cs b/src/FlaUI.Mcp/Tools/TypeTools.cs index 53ccc51..86f77a1 100644 --- a/src/FlaUI.Mcp/Tools/TypeTools.cs +++ b/src/FlaUI.Mcp/Tools/TypeTools.cs @@ -12,11 +12,13 @@ public class TypeTool : ToolBase { private readonly ElementRegistry _elementRegistry; private readonly PendingInvokeTracker _invokeTracker; + private readonly ProcessPolicy _processPolicy; - public TypeTool(ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null) + public TypeTool(ElementRegistry elementRegistry, PendingInvokeTracker? invokeTracker = null, ProcessPolicy? processPolicy = null) { _elementRegistry = elementRegistry; _invokeTracker = invokeTracker ?? new PendingInvokeTracker(); + _processPolicy = processPolicy ?? ProcessPolicy.AllowAll; } public override string Name => "windows_type"; @@ -82,6 +84,16 @@ public override Task ExecuteAsync(JsonElement? arguments) element.Focus(); Thread.Sleep(50); // Small delay to ensure focus } + else + { + // Ref-less input goes to whatever has keyboard focus, so verify + // the foreground window belongs to an allowed app. + var denied = _processPolicy.CheckForegroundWindowAllowed(); + if (denied != null) + { + return Task.FromResult(ErrorResult(denied)); + } + } // Type the text Keyboard.Type(text); diff --git a/tests/FlaUI.Mcp.Tests/ProcessPolicyTests.cs b/tests/FlaUI.Mcp.Tests/ProcessPolicyTests.cs new file mode 100644 index 0000000..35280d2 --- /dev/null +++ b/tests/FlaUI.Mcp.Tests/ProcessPolicyTests.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using PlaywrightWindows.Mcp.Core; +using Xunit; + +namespace FlaUI.Mcp.Tests; + +public class ProcessPolicyTests +{ + [Fact] + public void AllowAll_IsNotRestricted_AllowsEverything() + { + var policy = ProcessPolicy.AllowAll; + + Assert.False(policy.IsRestricted); + Assert.True(policy.IsNameAllowed("explorer")); + Assert.True(policy.IsNameAllowed(null)); + Assert.True(policy.IsProcessAllowed(0)); + Assert.True(policy.IsExecutableAllowed(@"C:\Windows\explorer.exe")); + Assert.Null(policy.CheckForegroundWindowAllowed()); + } + + [Fact] + public void IsNameAllowed_MatchesCaseInsensitivelyAndIgnoresExeSuffix() + { + var policy = new ProcessPolicy(new[] { "TabularEditor3.exe" }); + + Assert.True(policy.IsRestricted); + Assert.True(policy.IsNameAllowed("TabularEditor3")); + Assert.True(policy.IsNameAllowed("tabulareditor3")); + Assert.True(policy.IsNameAllowed("TABULAREDITOR3.EXE")); + Assert.False(policy.IsNameAllowed("TabularEditor2")); + Assert.False(policy.IsNameAllowed("explorer")); + Assert.False(policy.IsNameAllowed(null)); + Assert.False(policy.IsNameAllowed("")); + } + + [Fact] + public void IsExecutableAllowed_ReducesFullPathsToFileName() + { + var policy = new ProcessPolicy(new[] { "TabularEditor3" }); + + Assert.True(policy.IsExecutableAllowed(@"C:\Program Files\Tabular Editor 3\TabularEditor3.exe")); + Assert.True(policy.IsExecutableAllowed("TabularEditor3.exe")); + Assert.True(policy.IsExecutableAllowed("TabularEditor3")); + Assert.False(policy.IsExecutableAllowed(@"C:\Windows\explorer.exe")); + Assert.False(policy.IsExecutableAllowed("cmd.exe")); + } + + [Fact] + public void AllowlistEntries_AcceptFullPaths() + { + var policy = new ProcessPolicy(new[] { @"C:\Program Files\Tabular Editor 3\TabularEditor3.exe" }); + + Assert.True(policy.IsNameAllowed("TabularEditor3")); + } + + [Fact] + public void IsProcessAllowed_ChecksTheProcessName() + { + using var current = Process.GetCurrentProcess(); + + var allowing = new ProcessPolicy(new[] { current.ProcessName }); + var denying = new ProcessPolicy(new[] { "SomeOtherApp" }); + + Assert.True(allowing.IsProcessAllowed(current.Id)); + Assert.False(denying.IsProcessAllowed(current.Id)); + } + + [Fact] + public void IsProcessAllowed_DeniesUnknownProcessesWhenRestricted() + { + var policy = new ProcessPolicy(new[] { "TabularEditor3" }); + + Assert.False(policy.IsProcessAllowed(0)); + Assert.False(policy.IsProcessAllowed(-1)); + } + + [Fact] + public void FromEnvironment_ParsesSemicolonAndCommaSeparatedEntries() + { + var original = Environment.GetEnvironmentVariable(ProcessPolicy.EnvironmentVariable); + try + { + Environment.SetEnvironmentVariable(ProcessPolicy.EnvironmentVariable, " TabularEditor3.exe ; notepad, calc "); + var policy = ProcessPolicy.FromEnvironment(); + + Assert.True(policy.IsRestricted); + Assert.True(policy.IsNameAllowed("TabularEditor3")); + Assert.True(policy.IsNameAllowed("notepad")); + Assert.True(policy.IsNameAllowed("calc")); + Assert.False(policy.IsNameAllowed("explorer")); + } + finally + { + Environment.SetEnvironmentVariable(ProcessPolicy.EnvironmentVariable, original); + } + } + + [Fact] + public void FromEnvironment_UnsetOrEmpty_AllowsAll() + { + var original = Environment.GetEnvironmentVariable(ProcessPolicy.EnvironmentVariable); + try + { + Environment.SetEnvironmentVariable(ProcessPolicy.EnvironmentVariable, null); + Assert.False(ProcessPolicy.FromEnvironment().IsRestricted); + + Environment.SetEnvironmentVariable(ProcessPolicy.EnvironmentVariable, " "); + Assert.False(ProcessPolicy.FromEnvironment().IsRestricted); + } + finally + { + Environment.SetEnvironmentVariable(ProcessPolicy.EnvironmentVariable, original); + } + } + + [Fact] + public void DescribeDenied_NamesTheAllowedAppsAndEnvironmentVariable() + { + var policy = new ProcessPolicy(new[] { "TabularEditor3", "notepad" }); + + var message = policy.DescribeDenied("Process 'explorer'"); + + Assert.Contains("Process 'explorer'", message); + Assert.Contains("TabularEditor3", message); + Assert.Contains("notepad", message); + Assert.Contains(ProcessPolicy.EnvironmentVariable, message); + } + + [Fact] + public void CheckForegroundWindowAllowed_DeniesWhenForegroundIsNotAllowed() + { + // Whatever the foreground window is while tests run, it cannot belong + // to a process with this name. + var policy = new ProcessPolicy(new[] { "NoSuchProcessName_1b2c3d" }); + + var denied = policy.CheckForegroundWindowAllowed(); + + Assert.NotNull(denied); + Assert.Contains("allowlist", denied); + } +} From b548936a4c412e615a7717c9c8069eaaace3578b Mon Sep 17 00:00:00 2001 From: Daniel Otykier Date: Wed, 19 Aug 2026 10:35:42 +0200 Subject: [PATCH 2/2] Recommend handle capture over fullScreen in blocked-provider guidance The blocked-provider and modal-detected messages told the agent to use windows_screenshot with fullScreen: true, which is refused while the app allowlist is active - conflicting guidance discovered while driving TE3 with FLAUI_MCP_ALLOWED_APPS set. Window-handle capture is the better recommendation in every mode: its Win32 window-bounds fallback works while the provider is blocked, includes an overlapping modal once painted, and the dialog itself can be found and captured by its own handle via windows_list_windows (same process, so always allowed). Also corrects "will hang" to "will fail" (blocked tools fail fast since the modal-aware-invoke change) and mentions giving the dialog a moment to appear and take focus before sending keys, which a scripted sequence otherwise races. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + src/FlaUI.Mcp/Core/PendingInvokeTracker.cs | 5 +++-- src/FlaUI.Mcp/Tools/ClickTool.cs | 7 ++++--- src/FlaUI.Mcp/Tools/ScreenshotTool.cs | 9 ++++++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e656050..20f8477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Blocked-provider and modal-detected guidance messages now recommend capturing the dialog via its own window handle from `windows_list_windows` (which works while the provider is blocked and under an app allowlist) instead of `fullScreen: true` (refused while an allowlist is active), and mention waiting for the dialog to appear and take focus before sending keys. - Optional app allowlist via the `FLAUI_MCP_ALLOWED_APPS` environment variable (semicolon- or comma-separated process names). When set, the server refuses to launch, list, snapshot, screenshot or send input to any other application: window handles are only issued for allowed processes (scoping every ref-based tool), ref-less keyboard input requires an allowed foreground window and rejects the Windows key, and `fullScreen` screenshots are disabled. Unset means everything is allowed, as before. - The server now keeps the display awake while tools are actively being called, so Windows does not turn off the screen or show the lock screen in the middle of a long automation run. Implemented with a Windows power availability request (`PowerCreateRequest`/`PowerSetRequest` with `PowerRequestDisplayRequired` + `PowerRequestSystemRequired`) — the same mechanism video players and conferencing apps use, visible in `powercfg /requests`. The request is released after 5 minutes without a tool call; configure the idle period (or disable with `0`) via the `FLAUI_MCP_KEEP_AWAKE_SECONDS` environment variable. diff --git a/src/FlaUI.Mcp/Core/PendingInvokeTracker.cs b/src/FlaUI.Mcp/Core/PendingInvokeTracker.cs index 530b586..2dda0d0 100644 --- a/src/FlaUI.Mcp/Core/PendingInvokeTracker.cs +++ b/src/FlaUI.Mcp/Core/PendingInvokeTracker.cs @@ -85,8 +85,9 @@ public static string DescribeBlocked(PendingInvokeInfo info) : ""; var elapsed = (int)(DateTime.UtcNow - info.StartedUtc).TotalSeconds; return $"UI Automation for this app is blocked by a pending '{info.Description}' call " + - $"started {elapsed}s ago{modalPart}. UIA-based tools will hang until it completes. " + - "Interact with the dialog using windows_screenshot (fullScreen: true) to see it, " + + $"started {elapsed}s ago{modalPart}. Ref-based tools on this app will fail until it completes. " + + "To interact with the dialog: find its window handle via windows_list_windows (it is a separate " + + "window of the same process), see it with windows_screenshot using that handle, use " + "windows_send_keys (without ref) for keyboard input, or dismiss it; then retry."; } } diff --git a/src/FlaUI.Mcp/Tools/ClickTool.cs b/src/FlaUI.Mcp/Tools/ClickTool.cs index abcb81c..75409e6 100644 --- a/src/FlaUI.Mcp/Tools/ClickTool.cs +++ b/src/FlaUI.Mcp/Tools/ClickTool.cs @@ -158,9 +158,10 @@ private static McpToolResult PatternResult(PatternCallResult result, string comp PatternCallOutcome.Completed => TextResult(completedMessage), PatternCallOutcome.ModalDetected => TextResult( $"{completedMessage} — a modal dialog \"{result.ModalTitle}\" opened and is waiting for input. " + - "Note: UIA-based tools (windows_snapshot, windows_get_text) on this app will block until the " + - "dialog closes. Use windows_screenshot to see the dialog and windows_send_keys (without ref) " + - "or coordinate clicks to interact with it."), + "Note: UIA-based tools (windows_snapshot, windows_get_text) on this app will fail until the " + + "dialog closes. Give the dialog a moment to appear and take focus, find its window handle via " + + "windows_list_windows, see it with windows_screenshot using that handle, and interact via " + + "windows_send_keys (without ref) or coordinate clicks."), _ => TextResult( $"{completedMessage} — the app's handler is still running in the background. " + "Take a windows_screenshot to check the app's state; UIA-based tools may block until it completes."), diff --git a/src/FlaUI.Mcp/Tools/ScreenshotTool.cs b/src/FlaUI.Mcp/Tools/ScreenshotTool.cs index 8b417fe..3cd831b 100644 --- a/src/FlaUI.Mcp/Tools/ScreenshotTool.cs +++ b/src/FlaUI.Mcp/Tools/ScreenshotTool.cs @@ -109,12 +109,14 @@ public override Task ExecuteAsync(JsonElement? arguments) } // Element capture needs the UIA bounding rectangle, which hangs while - // the app's provider is blocked; suggest fullScreen capture instead. + // the app's provider is blocked; suggest window-handle capture instead + // (its Win32 fallback works while blocked, and fullScreen may be + // unavailable when an app allowlist is active). if (_invokeTracker.TryGetPending(_elementRegistry.GetProcessIdForRef(refId), out var pendingRef)) { return Task.FromResult(ErrorResult( PendingInvokeTracker.DescribeBlocked(pendingRef) + - " For screenshots, use fullScreen: true or a window handle instead of a ref.")); + " For screenshots, use a window handle instead of a ref.")); } capture = Capture.Element(element); @@ -132,7 +134,8 @@ public override Task ExecuteAsync(JsonElement? arguments) { return Task.FromResult(ErrorResult( "This app's UI Automation provider is blocked and its window bounds are unknown. " + - "Use fullScreen: true instead.")); + "Use windows_list_windows to find the window (or the open dialog) and capture it " + + "by that handle instead.")); } capture = Capture.Rectangle(bounds.Value); }