Single-binary kiosk lockdown for Windows 11 Home and Windows Server 2022 (RDP / physical console). Embedded WebView2 kiosk, password-gated keyboard, Chrome uninstalled, Edge launches blocked at the OS level, self-installing, self-updating, all from one ~7.9 MB exe.
kiosk-exit-guard.exe.sha256 sidecar. Every release publishes a tiny companion file alongside the exe (e.g. kiosk-exit-guard.exe.sha256) containing the SHA-256 hash of that release's binary as 64 hex characters on one line.
--update shortcut downloads the sidecar from the same release, recomputes the SHA-256 of the freshly-downloaded exe, and refuses to install if they don't match. v1.2.0 makes a missing sidecar prompt with a default-No zenity.Question — the admin has to explicitly acknowledge that integrity verification is unavailable before the update proceeds.Get-FileHash -Algorithm SHA256 kiosk-exit-guard.exe should produce the same hex string the sidecar contains.passwordPromptHTML and autoUpdateNotifyHTML both had .card { width: 100% } with no cap, so on a 1920×1080 landscape the input stretched into a tubular 1800px-wide field. v1.2.5 caps both at max-width: 540px. The first-run wizard card grew 520px → 560px to fit the v1.2.4 zoom field without crowding the help text.<wrap>s switched from height: 100vh + overflow: hidden (which clipped the top) to min-height: 100vh + overflow-y: auto. The first-run wizard's 7-field form (pw1, pw2, url, zoom, error, actions, header) is the biggest beneficiary on tight portrait viewports or with accessibility text scaling.document.documentElement.style.zoom = pct / 100 — i.e. zoom on <html>. A kiosk page that also runs its own document.body.style.zoom = '0.9' fallback (so the page renders at 90% in regular browsers without kiosk-exit-guard installed) would land on <body> while we landed on <html>. CSS zoom compounds across nested elements, so the rendered scale became <html> × <body> — e.g. 0.9 × 0.9 = 0.81 instead of the intended 0.9. v1.2.5 targets document.body.style.zoom; both code paths now write the same element. Admin config still wins because the injection fires at DOMContentLoaded AND window.load, both of which run after any inline parse-time script in the page body.CommonDesktopDirectory so every logged-in user sees them, but pre-v1.2.4 installs wrote to the per-user desktop with TargetPath = kiosk-exit-guard.exe (direct, UAC-triggering). Upgrading to v1.2.4 left those orphans next to the new public-desktop ones. v1.2.5's removeStalePerUserShortcuts() runs at the top of createDesktopShortcut and deletes the seven legacy .lnk filenames from the running user's desktop before writing the new public-desktop set. Idempotent — missing files are absorbed by Remove-Item -ErrorAction SilentlyContinue. Limitation: only the currently-running user's per-user desktop is cleaned.schtasks.exe /Run /TN KioskExitGuard_<Action>, the task runs as NT AUTHORITY\SYSTEM with HighestAvailable, and the task body invokes a new --shortcut-handler <flag> entry that spawns the action's UI into the active user's session via the same elevated-token logic the Service uses. Shortcuts also moved from the per-user desktop to CommonDesktopDirectory so every logged-in user sees them.
kiosk-exit-guard.exe --pause (etc.). The exe's requireAdministrator manifest meant every click fired UAC; for non-admin kiosk users the consent prompt couldn't be passed at all, so the operation never reached the HKLM-writing code that needs admin. Shortcuts also landed on the wizard runner's desktop (usually admin), so a separate non-admin kiosk user never saw them in the first place.installShortcutTasks() registers KioskExitGuard_Pause / _Resume / _LaunchKiosk / _Update / _SetURL / _Uninstall. Each task runs as SYSTEM with HighestAvailable, on-demand only. Default DACL on a SYSTEM/HIGHEST task grants BUILTIN\Users Read + Execute, so any logged-in user can /Run it without UAC. The task's action is <canonical-exe> --shortcut-handler <flag>; the handler calls pickActiveUserSession() and spawnFlagAsUserInSession() (the v1.2.4 sibling of spawnControllerInSession) to drop the action's UI on the user's desktop with their token + v1.2.3 elevated-linked-token swap.%SystemRoot%\System32\schtasks.exe; IconLocation stays canonicalInstallPath() so the brand icon is preserved; WindowStyle = 7 (Minimized) hides the brief schtasks console flash on click. createDesktopShortcut() writes to CommonDesktopDirectory with a per-user-desktop fallback for environments where the common dir resolves empty. removeDesktopShortcuts() wipes both locations so an upgrade from a pre-v1.2.4 install doesn't leave orphan .lnks on the admin's personal desktop.uninstallShortcutTasks() deletes every KioskExitGuard_* task via schtasks /Delete /F; missing-task errors are swallowed so an upgrade from <v1.2.4 (which never registered any tasks) doesn't fail uninstall.spawnFlagAsUserInSession runs against a user whose primary token has no elevated linked counterpart (a real Standard User, not a split-token admin), CreateProcessAsUser against the requireAdministrator exe still returns ERROR_ELEVATION_REQUIRED. Same failure mode the v1.2.3 spawnControllerInSession path has for that case. Fully supporting non-admin kiosk users requires a follow-up — either flip the manifest to asInvoker and move privileged ops into a SYSTEM-side dispatcher, or run the action body inside the SYSTEM task instead of spawning into the user session. Tracked as future work.Default page zoom (%) field next to the URL input, and the "Change Kiosk URL" shortcut now also prompts for a zoom percent. runWebViewKiosk reads the persisted value and injects an IIFE inside the existing w.Init block that sets document.documentElement.style.zoom = pct / 100 on both DOMContentLoaded and load — covering the initial paint and any SPA re-render that overwrites inline styles on <html>. Default is 100%; existing installs without the registry key behave exactly as before. Setting 90 once persists across kiosk respawns, pause/resume cycles, and auto-updates.
SendInput-based remote tool. Before v1.2.4 the LL keyboard hook ignored all injected events wholesale, so an admin remoting into a fullscreen kiosk via AnyDesk had no way to reach the pause flow without physical keyboard access. v1.2.4 carves out exactly one combo from the ignore-injected rule. Every other injected event still falls straight through unmodified — AnyDesk typing, paste, mouse macros, etc. keep working as before.
vkK with Ctrl + Shift + Alt held matches the new injected-path branch. Injected Win, injected Win+R, injected Ctrl+Esc, injected typing — all unchanged from v1.2.3.ctrlDown / shiftDown / altDown wrap GetAsyncKeyState, which Windows updates regardless of whether the modifier-down event was injected. AnyDesk's SendInput(Ctrl-down, Shift-down, Alt-down, K-down) chain leaves the async state reflecting all three modifiers by the time the K event arrives at the hook, so the combo is detected reliably.ourInjection excluded. A re-inject path that ever carries the same combo can't loop through this branch.promptOpen.Load() gate. Matches the existing physical-keyboard path so an AnyDesk admin who mashes the hotkey while the modal is already open doesn't spawn a second one.!injected && !ourInjection branch already handles them. v1.2.4 is purely additive — a remote admin path that didn't exist before, with no behavioral change for any other input source.service: spawnControllerInSession(1) failed: CreateProcessAsUser: The requested operation requires elevation. every tick. No controller meant no kiosk window, no keyboard hook, and no filter enforcement. v1.2.3 makes the spawn succeed by handing CreateProcessAsUser the user's elevated linked token instead of the filtered one.
spawnControllerInSession gets the target session's user token via WTSQueryUserToken, then calls CreateProcessAsUserW. On a split-token administrator (UAC enabled — i.e. every default Win 11 Home install), WTSQueryUserToken returns the filtered primary token — the one Explorer.exe runs under. app.manifest declares requireAdministrator, so Windows refuses to launch kiosk-exit-guard.exe under a non-elevated token and CreateProcessAsUser fails with ERROR_ELEVATION_REQUIRED (740). The supervisor retries every 2 s, producing the loop.WTSQueryUserToken, run the same elevated-linked-token swap the fallback path (tokenFromUserSessionProcess, used only when WTSQueryUserToken itself errors) already used at service_windows.go:960. elevatedLinkedToken returns (0, nil) when the token isn't split (UAC off, built-in administrator, already full) — non-split-token boxes are unaffected.service: swapped WTSQueryUserToken's filtered token for its elevated linked counterpart (the v1.2.3 fix kicked in) / service: elevatedLinkedToken on WTS token failed (...); proceeding with filtered token (rare GetTokenInformation error) / silence (token was already non-split) / the existing fallback-path message. A field kiosk-exit-guard.log now makes it obvious which spawn path each install is on.%LOCALAPPDATA% to %ProgramData%\KioskExitGuard\WebView2 and set its DACL via icacls /inheritance:r /grant:r SYSTEM:(OI)(CI)F /grant:r Administrators:(OI)(CI)F — SYSTEM and BUILTIN\Administrators only. But the controller runs as the kiosk user (a standard, non-admin account, spawned by the Windows Service in the user session), so msedgewebview2.exe in that token couldn't create EBWebView under the admin-only path. The DACL locked out the very process WebView2 was running as.ensureWebView2DataDir no longer calls ensureAdminOnlyDir. It does a plain os.MkdirAll and then icacls <path> /grant BUILTIN\Users:(OI)(CI)M, so the running user can read+write the profile. ensureAdminOnlyDir itself is unchanged — it still applies the hardened DACL to the update-staging directory under %ProgramData%\KioskExitGuard\staging\ (v1.1.8 CRITICAL#2), which correctly needs the kiosk user locked out./grant call site runs in the kiosk user's context and lacks WRITE_DAC on the existing admin-only dir, so the grant fails silently. From an elevated admin shell, either rmdir /S /Q C:\ProgramData\KioskExitGuard\WebView2 (simpler — next launch recreates with the correct DACL) or icacls "C:\ProgramData\KioskExitGuard\WebView2" /grant "Users:(OI)(CI)M" /T (preserves the profile, applies the ACE recursively).Users:Modify on the profile dir means the kiosk user can still plant a service-worker script via direct file-write, just at a less-obvious path. In practice the v1.1.8 fix already failed at this — all five WebView2 purposes (kiosk page, password modal, toast, wizard, update notify) share one DataPath, so a service worker installed by the kiosk page itself would have been inherited by the password modal regardless of DACL. The per-purpose-DataPath isolation that actually closes this hole is tracked as future work; v1.2.2 ships only the minimum change needed to restore functionality on Win 11.runAutoUpdateChecker goroutine polls GitHub's /releases/latest on startup (after a 60 s settle delay) and every 24 h thereafter. When a newer version is published it spawns a branded WebView2 modal — "An SK Filter update was pushed. Update to v{newver} now?" — with [Update Now] / [Remind Me Later] buttons. Update Now hands off to the existing --update flow (password modal, SHA-256 verify, atomic swap, Service restart); Remind Me Later defers to the next 24 h tick. The notification is purely additive — admins who prefer to drive the update themselves via the desktop shortcut don't lose anything.
--update, which pops the existing password modal so the admin still confirms intent. The point of the password is to confirm intent, not just identity.--silent-exit, --show-toast, --ask-password, --webview, --pause, --resume, --launch-kiosk, --update, --uninstall, --set-url, --reset, --service-install, --service-remove, --auto-update-notify itself) return earlier in main() without reaching the goroutine launch. A desktop-shortcut click won't accidentally hammer GitHub.Global\KioskExitGuardAutoUpdateNotify mutex. Admin-only DACL via the existing acquireAdminOnlyNamedMutex helper. The notify child holds it for its lifetime; a second 24 h tick that fires while a previous modal is still on-screen exits silently. Closes the "two stacked notify modals after a 24 h walk-away" hole.kgReady binding fired from DOMContentLoaded so the budget starts when the admin can actually click, not when WebView2 begins cold-starting. Same idiom as v1.2.0's password-modal timer fix. A 90 s pre-paint fallback covers catastrophic WebView2 failure.kiosk-exit-guard.log — auto-update check: triggered, auto-update check: on latest (v%s), auto-update check: new version v%s available; spawning notify child, auto-update notify: admin chose Update Now / Remind Me Later / timeout / window closed. Admins can audit when updates were available and what was chosen.wtsProcessInfoW was declared with explicit 4-byte padding fields that made the Go struct 32 bytes; Windows's WTS_PROCESS_INFOW on x64 packs the two DWORDs into a single 8-byte slot for a 24-byte total. The Go side walked the WTS-allocated array with the wrong stride and dereferenced garbage pointers for ProcessName. Worked by accident on the v1.1.10 / v1.1.11 test boxes because WTSQueryUserToken succeeded and the fallback was never invoked. Fix: drop the padding, add compile-time size assertions for both WTS structs — the build now fails if either ever drifts off 24 bytes.--update release prompts default-No instead of silently installing unverified. Closes the downgrade-attack vector where a release-edit-capable adversary deletes just the sidecar to defeat integrity verification.Global\KioskExitGuardFirstRunRelocate mutex prevents two simultaneous admin double-clicks from racing each other through the relocate-to-%ProgramFiles% path and corrupting the install.KIOSK_EXIT_GUARD_VIA_SERVICE from the child's env so a future bug can't smuggle Service-spawned-mode flags across the re-exec.kgReady binding fired from DOMContentLoaded instead of before w.Run(), so cold-start WebView2 paint latency doesn't eat the budget. A 60 s pre-paint fallback covers catastrophic WebView2 failure.pickActiveUserSession logs the chosen user (service: spawning controller in session 2 (state=Active, user=KIOSK\Administrator)) and every candidate session considered, so multi-RDP-user Server 2022 boxes are debuggable from kiosk-exit-guard.log.Global\KioskExitGuardUpdating mutex prevents double-clicked --update shortcuts from stacking and racing each other's exe rename.MoveFileEx(MOVEFILE_REPLACE_EXISTING) — the old canonical exe survives if the swap fails.cleanupInstallDir uses an allowlist (kiosk-exit-guard.exe, .exe.old, .exe.staging, .log, .log.old, resource.syso, staging\) so uninstall can't nuke an admin-placed file.acquireAdminOnlyNamedMutex helper using SDDL D:(A;;0x1F0001;;;BA)(A;;0x1F0001;;;SY) so a kiosk-user logon script can't pre-create the mutex and DoS the legitimate controller.quser reports administrator rdp-tcp#0 ID 2 Active while WTSGetActiveConsoleSessionId() returns the empty physical-console session ID 1. The supervisor hardcoded sessionID = WTSGetActiveConsoleSessionId() and never asked "is anyone else logged in elsewhere?" — no controller ever spawned, the kiosk was unprotected after every reboot. Fix: new pickActiveUserSession() in service_windows.go walks WTSEnumerateSessionsW, calls WTSQuerySessionInformationW(WTSUserName) on every WTSActive session, and picks the console session if it qualifies (preserves Win11 / laptop behavior) or the lowest-numbered other WTSActive session with a logged-in user (Server 2022 / RDP). supervisorLoop logs service: spawning controller in session N (state=Active, user logged in) so admins reading kiosk-exit-guard.log can see which session won. Plus three smaller items: restartExplorer reads HKLM\...\Winlogon\Shell and skips the kill / restart when the registered shell isn't explorer.exe (Server Core / custom-shell setups); IFEO removal and Chrome uninstall silently absorb "not installed" instead of logging confusing errors on fresh Server 2022 boxes; and the --update flow drops the "Checking GitHub for updates…" toast and the standalone confirm — the password modal's subtitle mentions the new version number, combining confirm + auth into one screen. See the changelog for per-finding root cause + fix.
%ProgramFiles%\KioskExitGuard\ at first run so the SCM-registered binary path lives in an admin-only directory; --update stages downloads in an icacls-locked %ProgramData% subdirectory and verifies SHA-256 against a kiosk-exit-guard.exe.sha256 sidecar; the explorer-token fallback authenticates the candidate process by QueryFullProcessImageName against %SystemRoot%\explorer.exe; all four in-process WebView2 instances share an admin-only user-data folder under %ProgramData%; the HKLM password-hash key has an explicit SetNamedSecurityInfo DACL (SYSTEM + Administrators only) so the bcrypt hash isn't offline-crackable from a standard user; the LL keyboard hook is installed earlier in main() to close the kill-old-controller-then-install-hook gap; installStartupTask passes exe path + task name as env vars with a base64-encoded script body; and isLaunchedByService authenticates the supervising parent via parent-PID image-path lookup instead of trusting a forgeable env-var marker.Global\KioskExitGuardControllerRunning mutex; controller panics now surface a "SK Filter restarted after an internal error" toast; first-run setup builds one combined status dialog from (svcErr, taskErr) instead of stacking warnings; the pause shortcut writes a pause-just-applied.flag marker so the controller's 30 s watchdog doesn't relaunch the kiosk before the 2 s sync loop catches up; --update polls SCM until the service actually reaches svc.Stopped before renaming the exe; modal-spawn failures surface a toast instead of being silently swallowed; exit-after-failure toasts use a synchronous variant so os.Exit(1) doesn't kill the toast child mid-paint; uninstall mentions the Windows Service in its confirm dialog and verifies removal via serviceStillExists().gopsutil to find explorer.exe in the active session; on some installs gopsutil's snapshot returned empty when called from the Session 0 LocalSystem service, so the supervisor logged "no explorer.exe found in session 1" every 2 seconds and the kiosk had zero protection after reboot. Switched to WTSEnumerateProcessesExW from wtsapi32.dll — the Win32 API designed for service-side cross-session enumeration. Broadened the candidate list to explorer.exe · sihost.exe · taskhostw.exe · RuntimeBroker.exe · StartMenuExperienceHost.exe, each validated against its canonical %SystemRoot% path. Also silenced two benign-but-alarming log lines (tightenHKLMConfigDACL ERROR_FILE_NOT_FOUND on fresh installs; parentProcessImagePath ERROR_INVALID_PARAMETER on the v1.1.8 relocate-and-reexec flow).For the full release-by-release history, including the v1.1.0 Service supervisor introduction and the v1.1.1 – v1.1.7 stabilization burst that preceded the v1.1.8 audits, see the changelog.
Background runAutoUpdateChecker goroutine polls GitHub's /releases/latest on startup (after a 60 s settle delay) and every 24 h thereafter. Network errors silently logged; admins reading kiosk-exit-guard.log see every check outcome (on latest (v%s) or new version v%s available). Only runs in the long-lived controller — every short-lived flag invocation returns before the goroutine launches.
When a newer release is published, the controller spawns a branded WebView2 modal — "An SK Filter update was pushed. Update to v{newver} now?" — with [Update Now] / [Remind Me Later]. Update Now hands off to the existing --update flow (password modal, SHA-256 verify, atomic swap, Service restart). 60 s auto-dismiss, Global\KioskExitGuardAutoUpdateNotify mutex prevents stacking, fullscreen topmost via the same makeModalFullscreenTopmost+forceForeground idiom as the password modal.
Supervisor walks WTSEnumerateSessionsW and picks the lowest-numbered WTSActive session with a logged-in user (verified via WTSQuerySessionInformationW(WTSUserName)), preferring the console session when it qualifies. Win11 / laptop behavior unchanged; Server 2022 / RDP newly supported.
"Checking GitHub for updates…" toast and the separate "download and install?" zenity.Question are gone. Password modal's subtitle mentions the new version number, combining confirm + auth into one screen. The "you're on v%s. No update available." branch is kept so clicking Update is never silent.
Reads HKLM\...\Winlogon\Shell and skips the taskkill /F /IM explorer.exe when the registered shell isn't explorer.exe (Server Core, custom shells). NoTaskbar policy still gets written; it just won't take effect until next logon.
Cross-session process enumeration uses WTSEnumerateProcessesExW from wtsapi32.dll (Win32's service-designed API) instead of gopsutil's userland snapshot, which was returning empty on some installs when called from Session 0. Candidate list broadened to explorer.exe · sihost.exe · taskhostw.exe · RuntimeBroker.exe · StartMenuExperienceHost.exe, each validated against its canonical %SystemRoot% path via QueryFullProcessImageName.
A user who pressed Ctrl+Shift+Alt+K, saw the modal, then walked away used to leave the fullscreen password screen up forever. askPasswordModalInProcess arms time.AfterFunc(30*time.Second, ...); kgSubmit / kgCancel reset the timer so an actively-typing user isn't yanked mid-attempt.
v1.1.4 co-installed both Service and AtLogon task; at logon both fired within ~1 s and the loser was killed by the winner's killRunningController. New Global\KioskExitGuardControllerRunning named mutex: second mover logs and os.Exit(0)s cleanly. No respawn loop, no kiosk blink.
First-run copies the exe to %ProgramFiles%\KioskExitGuard\ and re-execs from there before registering the Service binary path, scheduled task, or desktop shortcuts. SCM-registered path now lives in an admin-only directory — a kiosk user can no longer swap the binary and have the supervising Service respawn attacker code as LocalSystem.
Downloaded exe stages under %ProgramData%\KioskExitGuard\staging\ (DACL via icacls /inheritance:r) instead of %TEMP%; SHA-256 verified against a kiosk-exit-guard.exe.sha256 sidecar asset published by the release workflow. Mismatch aborts with a zenity.Error.
Default HKLM\Software inherits BUILTIN\Users:KEY_READ — any local user could read the bcrypt hash and crack it offline. SetNamedSecurityInfo(SE_REGISTRY_KEY, SDDL=D:PAI(A;CI;KA;;;SY)(A;CI;KA;;;BA)) on every controller startup grants only SYSTEM + Administrators; existing installs heal on first launch of v1.1.8.
All four webview2.NewWithOptions call sites (password modal, toast, first-run wizard, kiosk page) share %ProgramData%\KioskExitGuard\WebView2\ with the same locked-down DACL. The default %LOCALAPPDATA% profile was kiosk-user-writable — a poisoned service worker could have intercepted the password modal's kgSubmit host-object call.
Both auto-start mechanisms registered on install. Service is the in-session respawn supervisor (LocalSystem in Session 0). Scheduled task is the AtLogon fallback when WTSQueryUserToken + the v1.1.3 user-session-process fallback both fail. Closes the "filter only runs when I re-click the exe" failure mode.
Ctrl+0, Ctrl+-, Ctrl++ / Ctrl+= (plus numpad variants) pass through to the kiosk WebView2 page so admins / kiosk visitors can adjust zoom without entering the password. Ctrl-only — Win+0 / Alt+- still hit the lockdown.
The kiosk page lives in this exe, not a Chrome subprocess. Fullscreen, topmost, frameless, JS-locked to refuse navigation away from the kiosk URL. Context menus, dev tools, status bar, zoom, and NewWindowRequested all disabled / rejected.
KioskExitGuardSvc runs as LocalSystem in Session 0 and respawns the user-session controller via CreateProcessAsUserW. Kiosk user can't reach SCM, so they can't stop the supervisor.
LL keyboard hook captures every Ctrl/Win/Alt combo plus Win-alone, opens the SK Filter password modal. Correct password re-injects the original combo via SendInput with a per-process random ExtraInfo nonce. Wrong password / cancel = swallowed. Ctrl+R, F5, and Ctrl-zoom combos pass through.
Reads Chrome's UninstallString from the registry and runs the official uninstaller with --force-uninstall. Clean removal, idempotent if Chrome isn't installed.
HKLM\…\Image File Execution Options\msedge.exe\Debugger redirects launches to kiosk-exit-guard --silent-exit. Same for chrome.exe and the accessibility helpers (sethc, osk, narrator, utilman, magnify) that close the Sticky-Keys-5x-Shift and Ease-of-Access escapes. Re-applied on every controller launch so a Windows Update can't strip them.
Embedded manifest. Every manual launch requests admin consent. The Service spawns the controller via CreateProcessAsUserW with the user's linked-elevated token so logon launches are silent.
Bcrypt hash in HKLM\Software\KioskExitGuard\PasswordHash. Admin-write only — a standard kiosk user can't bypass by deleting a file. DACL is tightened to SYSTEM + Administrators on every controller startup (v1.1.8+).
Ctrl+Shift+Alt+K or the "Pause SK Filter" desktop shortcut. Password → duration picker (1 / 5 / 10 / 20 / 30 / 45 min or Custom 1–100). Edge becomes launchable, HKCU lockdown lifts, kiosk window closes. Auto-resume when the timer expires.
Filter active: DisableTaskMgr, NoRun, NoTrayContextMenu, NoViewContextMenu, NoTaskbar all set to 1 under HKCU. Cleared during pause or by --reset.
"Update SK Filter" shortcut hits GitHub's releases/latest, downloads the matching kiosk-exit-guard.exe.sha256 sidecar asset (64-hex digest), recomputes the SHA-256 of the freshly-downloaded exe and aborts on mismatch, then atomic-renames the running exe (.old rollback target) and restarts the Service. v1.2.0 makes a missing sidecar prompt default-No instead of silently proceeding.
SK Filter
Please enter your password to continue.
One binary; the first command-line argument selects the role. Since v1.1.0 there are two long-lived processes per device: a Windows Service supervisor running as LocalSystem in Session 0, and a user-session controller it spawns via CreateProcessAsUserW.
LocalSystem in Session 0. pickActiveUserSession() walks WTSEnumerateSessionsW to find the user's session (v1.1.11), gets the user token, spawns the controller into the user session, watches it, respawns on death. No UI.
CreateProcessAsUserW into the user session. Owns the LL keyboard hook, filter-mode state, registry lockdown, and supervises the WebView2 kiosk child.
go-webview2 second-instance panic.
WTSEnumerateSessionsW, picks console if it has a user, else lowest-numbered other Active session with a user
WTSQueryUserToken (or user-session-process fallback) + CreateProcessAsUserW
If the controller dies (crash, manual kill, etc.), the Service spawns a fresh one within ~1 s. The kiosk user can't stop the Service — SCM rejects non-admin commands. The co-installed AtLogon scheduled task acts as a fallback for installs where the Service spawn path fails outright; Global\KioskExitGuardControllerRunning keeps exactly one controller alive when both fire.
The re-injected events carry a random per-process nonce in ExtraInfo so the LL hook recognizes them as our own and lets them through. Wrong password → keystroke stays swallowed, "Wrong password" toast appears.
Two equivalent entry points. Both invoke the same password modal and duration picker.
When the desktop button is used, a 2-second polling goroutine in the running controller picks up the state change from the pause file and mirrors it into in-memory flags. The v1.1.9 pause-just-applied.flag marker suppresses the controller's 30 s watchdog for the first 5 s after pause so it can't relaunch the kiosk before the sync loop catches up.
| State | Keyboard | Task Manager | Run dialog | WebView2 kiosk | Edge |
|---|---|---|---|---|---|
| Active (default) | Any Ctrl/Win/Alt combo → SK Filter password modal → re-injected if correct. Ctrl+R / F5 / Ctrl-zoom pass through. |
Disabled by policy | Disabled by policy | Fullscreen, topmost, frameless | IFEO-blocked from launching |
| Paused (temporary, 1–100 min) | All keys pass through | Open | Open | Closed | Allowed (IFEO block lifted) |
Chrome remains uninstalled in both states — it was removed during first-run setup and isn't reinstalled by the pause. Only Edge re-launches are re-enabled during a pause.
When the pause timer fires, the state flips back to Active automatically: Edge IFEO block is re-applied, the registry lockdown is reapplied, and the WebView2 kiosk relaunches. No user action required.
C:\Program Files\KioskExitGuard\ if it's launched from anywhere else.pickActiveUserSession() automatically targets the RDP session instead of the empty physical-console session.
kiosk-exit-guard.exe # controller mode — LL hook + watchdog (spawned by Service) kiosk-exit-guard.exe --service-run # SCM-only — supervising Service (Session 0, LocalSystem) kiosk-exit-guard.exe --service-install # admin: register & start KioskExitGuardSvc kiosk-exit-guard.exe --service-remove # admin: stop & unregister the Service kiosk-exit-guard.exe --pause # password + duration picker (desktop button) kiosk-exit-guard.exe --resume # end pause early, no password (desktop button) kiosk-exit-guard.exe --update # GitHub releases/latest + SHA-256 + atomic replace (desktop button) kiosk-exit-guard.exe --uninstall # password + confirm, full teardown (desktop button) kiosk-exit-guard.exe --launch-kiosk # manually respawn the WebView2 kiosk child kiosk-exit-guard.exe --set-password # change the password (UAC-elevated) kiosk-exit-guard.exe --set-url # change the kiosk URL (UAC-elevated) kiosk-exit-guard.exe --reset # password-gated, clears registry policies, IFEO, filter mode kiosk-exit-guard.exe --webview # internal: renders the kiosk window kiosk-exit-guard.exe --silent-exit # internal: IFEO Debugger redirect handler kiosk-exit-guard.exe --ask-password # internal: child-process password modal kiosk-exit-guard.exe --show-toast # internal: child-process toast renderer
The controller is normally spawned by the Service into the active user session via CreateProcessAsUserW, not by the user directly. First-run setup registers the Service and starts it automatically. Verify with Get-Service KioskExitGuardSvc or sc query KioskExitGuardSvc.
--reset requires the configured password. If lost entirely, an admin can wipe HKLM\Software\KioskExitGuard via regedit to fully reset — see the admin runbook for the full recovery script.
CI at .github/workflows/release.yml rebuilds and releases on every v* tag push, attaching both kiosk-exit-guard.exe and a kiosk-exit-guard.exe.sha256 sidecar that the in-app --update flow verifies against.
git tag v1.1.11 && git push origin v1.1.11
Local build:
go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest goversioninfo -64 versioninfo.json go build -ldflags="-H windowsgui -s -w" -o kiosk-exit-guard.exe
Source · github.com/Shalom-Karr/kiosk-exit-guard · MIT