diff --git a/CHANGELOG.md b/CHANGELOG.md
index 806cd66..20f8477 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ 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.
### 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/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/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/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 5a797d9..3cd831b 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))
@@ -99,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);
@@ -122,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);
}
@@ -144,6 +157,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);
+ }
+}