Skip to content

feat(desktop): bind Windows keys to the approved recipient and refuse elevated apps - #167

Merged
egdev6 merged 2 commits into
Gentleman-Programming:mainfrom
MarsSall:feat/windows-cc6c-typed-elevated
Nov 1, 2026
Merged

egdev6 merged 2 commits into
Gentleman-Programming:mainfrom
MarsSall:feat/windows-cc6c-typed-elevated

Conversation

@MarsSall

@MarsSall MarsSall commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

What

CC6c, the last slice of CC6. It builds on #164 (the shared keyboard_refusal hook) and #165 (Windows app identity). The code is still unwired until part 3's impl Desktop, so no behavior changes on any platform.

  • win::post_typed(event, recipient) binds a typed chunk, a Return, or a key to the recipient the shared guard approved, exactly as feat(desktop): bind computer-control keys to the app the agent saw and approved #159 does on macOS:

    • the front app is target_of the pinned foreground root;
    • the focused app is filtered to differ from the front one;
    • the pinned focus window must belong to the root's process, the front app, or the approved focused app;
    • the windows must be pinned again unchanged after the global focus query.

    A mismatch sends nothing ("The app receiving the keys changed before typing, so nothing was sent.").

  • win::keyboard_refusal(app) is the Windows side of feat(desktop): refuse keyboard input to apps the platform says cannot take it #164's hook. It compares the target process token's integrity level with ours, because Windows silently drops input sent to a higher-integrity process:

    • higher: "{app} runs as administrator, so Gentle Dot cannot send it keys.";
    • unreadable (process or token cannot be opened, the label cannot be read, invalid SID): refused too, with "Gentle Dot cannot tell whether {app} runs as administrator, so it does not send it keys.";
    • equal or lower: keys go through.

Review notes folded in

From #164:

  • runs_as_administrator(name) lives in mod.rs, and both win.rs and the fake use it. The mcp tests still assert the full literal, so a wording change shows up in them.
  • The unreachable target.is_none() is gone: the start of a call now pins Option<(root, focus)>, or returns "Cannot tell which window is in front, so nothing was sent."
  • A window change caught before anything was sent in this call (done == 0) says "changed before typing, so nothing was sent"; after that, "changed while typing, so the rest was not sent".
  • elevated_front_refuses_key_at_the_recheck: an app that becomes elevated while key reads the focus is refused at the recheck, and nothing is posted. It passed as soon as it was written, because feat(desktop): refuse keyboard input to apps the platform says cannot take it #164's hook already covers that path, so it pins existing behavior rather than going RED.

From #165:

  • title() is now title_checked(hwnd).unwrap_or_default().

Tests

New in win.rs:

  • this_process_takes_its_own_keys (CI): our own token's level is accepted.
  • a_process_whose_level_cannot_be_read_gets_no_keys (CI): a nonexistent pid gets the "cannot tell" refusal.
  • keys_for_another_recipient_are_not_posted (CI): a recipient the windows in front do not belong to gets an error. On a runner with nothing in front, that error is "Cannot tell which window is in front"; this test checks the returned error, not a send count.
  • an_elevated_console_gets_no_keys (#[ignore], interactive): start a console with "Run as administrator" and pass its pid in GENTLE_DOT_ELEVATED_PID. I'll run it on the PC with CC8's end-to-end pass.

Evidence:

  • Linux cargo test --lib: 509 passed, 0 failed, no warnings.
  • Fork Windows run 41302014346: both jobs green, 528 passed, 0 failed, 16 ignored, no Rust warnings; the three new CI tests pass.
  • An independent read-only verification compared post_input with the source line by line. It found no bypass of the recipient check, and reviewed the token code: the two-call GetTokenInformation, an aligned u64 buffer, the SID validated before its sub-authorities are read, and every handle closed. No blockers.

Known limits (inherited, not new)

  • Cleanup after a failed send releases held keys best-effort; Windows may refuse a release.
  • Reading the focus and calling SendInput cannot be made atomic.
  • Pointer input to elevated windows is dropped by Windows too. That is outside this slice and tracked as a follow-up.

@egdev6 this is CC6c. Part 3 (UI Automation and impl Desktop, removing the dead_code allow) is next.

… elevated apps

post_typed checks that the windows in front still belong to the front
and focused apps the shared guard approved. keyboard_refusal compares
the target's token integrity level with ours, since Windows silently
drops input sent to an app run as administrator, and refuses an app
whose level cannot be read. Review notes from Gentleman-Programming#164 and Gentleman-Programming#165: one shared
administrator message, the before/while typing wording picked by what
was sent, an elevation test for the key recheck, and title() reuses
title_checked().
@egdev6

egdev6 commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

@MarsSall the binding is solid. post_input computes the front with target_of(root) like the shared guard, requires the pinned focus window to belong to an approved process, and pins the windows again after the global focused_app() query. Key downs are rechecked too, and keyboard_refusal fails closed with careful token handling. All the #164 and #165 notes are in, and thanks for flagging that the key recheck test pins existing behavior instead of going RED.

One change before merge:

  • A contradictory message after the first chunk. post_input picks "before" or "while" from done within this call, but mcp.rs calls it once per chunk and appends Typing stopped within characters {posted+1}-{posted+characters} of {total}. to any post_typed error. A window change caught at the start of the second chunk gives the agent: "The app receiving the keys changed before typing, so nothing was sent. Typing stopped within characters 21-40 of 45." That says nothing was sent after 20 characters landed, and reports an uncertain range when nothing of this chunk went out. The up-front validation has the same issue, since it always returns KEYS_CHANGED_BEFORE_TYPING. Only mcp.rs knows posted, so the backend should signal "refused before sending anything in this call" (a typed error or its own message) and let mcp.rs report it as "stopped after 20 of 45". A fake-backed mcp test that makes the second chunk's post_typed refuse would pin it.

Worth checking on the PC before merge rather than in CC8:

  • Which message an elevated app actually gets. I believe OpenProcessToken(TOKEN_QUERY) on an elevated process usually fails from a non-elevated one (I have not checked it). If so, the common case lands on "cannot tell whether ... runs as administrator", not the feat(desktop): refuse keyboard input to apps the platform says cannot take it #164 wording, and an_elevated_console_gets_no_keys would fail. Still safe, but if that is what happens, an access-denied token on a same-session process is itself a good sign of elevation and could map to runs_as_administrator.

Fine for part 3:

  • valid requires a non-null focus window. When the foreground window has no focused child, Windows sends keys to that window, but post_typed refuses with "changed before typing" although nothing changed. Refusing is safe, but the message should say what happened.
  • keys_for_another_recipient_are_not_posted accepts either error, and on a headless runner it is almost always CANNOT_TELL_FRONT, so the recipient mismatch path never runs in CI. Worth noting it as covered only interactively.

…ady sent

Gentleman-Programming#167: post_input chose "before"/"while" from its own call, but the
helper posts one chunk per call and appended an uncertain range, so a
change at the second chunk said nothing was sent after 20 characters.
post_typed now returns a TypedError: RecipientChanged and FrontUnknown
mean nothing of that post went out, and the helper reports them after
the characters already sent. A token that refuses to be opened is taken
as an elevated app.
@MarsSall

MarsSall commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @egdev6, both points are addressed in 73bd200.

1. Contradictory message after the first chunk: fixed.

post_typed now returns a TypedError instead of a string:

  • RecipientChanged and FrontUnknown mean nothing of that post went out. post_input returns them from the up-front validation, from a failed start, and from a change caught before its first batch.
  • Failed(String) means part of it may have.

The helper words the first two itself (TypedError::message(sent)) and reports them as "Typing stopped after {posted} of {total} characters.", so there is no uncertain range. Failed keeps "within characters a-b". A change caught after the first batch of a call is Failed(KEYS_CHANGED_WHILE_TYPING), which is right there because part of that post did go out.

The Desktop::post_typed default is post(...).map_err(TypedError::Failed), so macOS and Linux behave as before.

New fake-backed tests:

  • a_backend_refusal_is_reported_after_the_characters_already_sent covers RecipientChanged and FrontUnknown, at the first and at the second chunk of a 45-character type. Each case asserts the full message and the events sent. It was RED first with exactly your example: "…changed before typing, so nothing was sent. Typing stopped within characters 21-40 of 45."
  • a_backend_refusal_of_a_key_says_nothing_was_sent.

2. Which message an elevated app gets: checked on the PC.

From a non-elevated PowerShell in the desktop session (its own token reads 0x2000), against an elevated PowerShell in the same session, OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + OpenProcessToken(TOKEN_QUERY) + GetTokenInformation(TokenIntegrityLevel) succeed and read 0x3000. So the common case gets "{app} runs as administrator, so Gentle Dot cannot send it keys.", not the "cannot tell" one. I still took your suggestion as a guard: an ERROR_ACCESS_DENIED from OpenProcessToken now maps to runs_as_administrator, and any other failure stays "cannot tell".

Part 3 notes, recorded:

  • A foreground window with no focused child is refused with "changed before typing" although nothing changed. CC7 gets its own reason and message.
  • keys_for_another_recipient_are_not_posted reaches the recipient check only interactively. Its doc comment now says so.

Linux cargo test --lib 511 passed / 0 failed; fork Windows run 41317066270 green (530 passed, 0 failed, 16 ignored, no Rust warnings).

@egdev6

egdev6 commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

@MarsSall 73bd200 resolves it. TypedError puts the wording where posted lives, Failed keeps the uncertain range only when part of the post may have gone out, and the four RED-first cases pin it at the first and second chunk. Thanks for checking the token on the PC too: my guess was wrong, and the common case gets the #164 wording. Good from my side.

Two small things, fine for part 3:

  • Integrity::Denied's comment says "a process of this user and session", but nothing checks user or session. Since a same-session elevated process does let its token be read, Denied will mostly come from another user's process (Run as different user), which then gets "runs as administrator". Still refused, just with an inexact reason: either fix the comment or map it to "cannot tell".
  • A backend refusal of key reads "changed before typing", while the helper's own key recheck says "changed before the key was sent, so it was not sent." Using the latter for both would keep key consistent.

@egdev6
egdev6 merged commit 06a913e into Gentleman-Programming:main Nov 1, 2026
2 checks passed
egdev6 pushed a commit that referenced this pull request Nov 1, 2026
The element under a point and the focused element with its window's
default button come from UI Automation, with a 0.5 s timeout; anything
it does not report is unreadable and asks for a confirmation. focused_app
now also checks UI Automation's focused process. WinDesktop implements
the helper's Desktop with this backend. COM is entered in one place.
Review notes from #167: a token that cannot be read is no longer taken
as elevated, a backend refusal of a key is worded like the key recheck,
and a window in front with no keyboard focus says so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants