Beta v1.0.0 — Report bugs · Discussions
Self-applied behavioral accountability for Windows. Block websites, apps, and keywords. Configure escalating punishments. Optionally lock yourself into a time-bound commitment you can't undo.
Think of it as a parental control system you install on your own machine, with the key thrown away for a set period.
Built by Lidprex Labs, a subsidiary of Lidprex. Licensed under GPL v3.
Oathkeeper runs as two separate Windows processes:
-
oathkeeper_srv.exe— C++20 engine. Runs as a scheduled task at user logon (/sc onlogon /rl HIGHEST) in the user's interactive session. Handles all low-level OS interception: keyboard hook (WH_KEYBOARD_LL), window title polling (EnumWindows), hosts file manipulation, network interface disabling, screenshots, email sending. Deployed toC:\Windows\System32. Exposes a named pipe for the UI. -
Oathkeeper.exe— C# WPF frontend (asInvoker, no UAC). Provides the user-facing interface: blocked item lists, settings, pledge setup, email config, system tray. Communicates with the engine via named pipe IPC (\\.\pipe\OathkeeperPipe).
The engine runs independently as a scheduled task and keeps enforcing even if the UI crashes. If the engine task isn't running, the UI works for hosts-file blocking only (via HostsManager), but loses keyboard hook, window monitoring, and all punishments.
The engine runs as a scheduled task rather than a Windows Service because EnumWindows and SetWindowsHookEx(WH_KEYBOARD_LL) require an interactive user session — Service Session 0 cannot access the user's desktop. See docs/ARCHITECTURE.md for the full architecture diagram and security model.
- Windows 10/11 (64-bit)
- .NET 9.0 SDK
- Visual Studio 2022+ (C++ and .NET workloads)
- Inno Setup 6 (
winget install JRSoftware.InnoSetup) - WiX Toolset v7 (
dotnet tool install --global wix)
.\cook.ps1 # Build everything (engine + UI + both installers)
.\cook.ps1 engine # Build C++ engine only
.\cook.ps1 ui # Build C# UI only
.\cook.ps1 installer # Build both MSI + EXE installers
.\cook.ps1 verify # Build + run self-checks
.\cook.ps1 clean # Clean build artifacts- Run
Oathkeeper_Setup_v1.0.0-beta.exeorOathkeeper_v1.0.0-beta.msias Administrator - The installer creates the
OathkeeperEnginescheduled task (runs at logon) - Launch
Oathkeeper.exefrom the Start Menu or desktop shortcut
- Website blocking — via hosts file + 250ms window title scanning + DNS flush
- Application blocking — process name substring matching via
EnumWindows - Keyword blocking — window title substring matching (case-insensitive, any position)
- Adult Shield — 10,000+ adult domain blocklist via hosts file (toggle)
- 6 punishments — Black Screen, Hardware Beep, Cut Internet, Flash, Keyboard Freeze, Drunk Mouse
- Punishment escalation — duration scales linearly per offense, capped per type
- Accountability Email — screenshot + violation report sent to trusted recipient
- Auto-Apology Protocol — 30s countdown window, confirm to cancel + send apology email
- Accountability Partner — anti-format notification system
- Commitment / Pledge system — time-locked with SHA-256 password, time cheat detection
- Smart Intent Blocker — block specific window titles inside allowed processes
- Bypass mechanism — secret 10-char code (SHA-256 hashed, never stored in plaintext)
The engine operates a fixed-rate polling loop in ServiceWorkerThread (main.cpp:579):
| Cycle | Period | What Happens |
|---|---|---|
| Window poll | 250 ms | EnumWindows scans visible window titles against blocklist |
| Violation cooldown | 5 s | Post-violation sleep prevents duplicate triggers |
| Config hot-reload | ~250 ms | GetFileModTime checked every iteration |
| Time tamper check | 30 s | Counter >= 120 iterations × 250 ms triggers state-file expiry check |
| State save | 60 s | Counter >= 240 iterations × 250 ms persists offense counts to disk |
| Network integrity | 30 s | NetworkFilter verifies hosts file still contains Oathkeeper sentinel |
Key press → KeyboardHook (WH_KEYBOARD_LL, 0 latency, callback on same thread)
↓ keyword match → erase typed buffer + fire m_onBlocked
↓ bypass match (SHA-256 of last 10 chars) → EraseLastChars(10) + fire m_onBypass
Window title → EnumWindows (every 250 ms via WindowMonitor thread)
↓ Tier 1: Title keyword match (substring, case-insensitive)
↓ Tier 2: Browser process domain match (strip protocol/path, substring on proc name)
↓ Tier 3: Smart blocked app (process + title allow/block lists)
↓ Tier 4: Process name blocklist (substring, case-insensitive)
→ m_onViolation → PunishmentManager.Execute()
The keyboard hook runs synchronously on the input thread — key processing has zero polling delay. Window monitoring is poll-based with a worst-case detection latency of ~250 ms.
All six offense counters (screen_freeze, hardware_beep, cut_internet, flash, keyboard_freeze, drunk_mouse) increment on every violation regardless of which punishments are currently enabled (PunishmentManager.cpp:147-154).
Duration scales linearly with the cumulative offense count for that punishment type:
duration = min(baseDurationMs × offenseCount, CAP_MS)
| Punishment | Base Duration | Formula | Cap | Default Enabled |
|---|---|---|---|---|
| Screen Freeze | 10,000 ms | min(10000 × count, 120000) |
120 s | Yes |
| Keyboard Freeze | 10,000 ms | min(10000 × count, 300000) |
300 s | No |
| Hardware Beep | 2,000 ms (floor) | max(2000, 1500 × count) |
uncapped — confirm this is intentional | Yes |
| Cut Internet | 60,000 ms (fixed) | fixed 60 s | — | No |
| Flash | 5,000 ms (fixed) | fixed 5 s | — | No |
| Drunk Mouse | 10,000 ms (fixed) | fixed 10 s | — | No |
Open question: Screen Freeze and Keyboard Freeze both cap their escalation. Hardware Beep doesn't, and since offense counters accumulate across every violation regardless of which punishments are enabled/disabled at the time, a counter that's been climbing while Beep was off could produce an unexpectedly long beep sequence once it's re-enabled. Confirm in
PunishmentManager.cppwhether this is deliberate (beep is low-severity, an uncapped duration is acceptable) or a missing cap that should be added to match the other two scaling punishments — then remove this note.
Example progression (Screen Freeze):
| Offense # | Duration | Notes |
|---|---|---|
| 1 | 10 s | First violation |
| 2 | 20 s | |
| 3 | 30 s | |
| ... | ... | |
| 12 | 120 s | Hits cap at 12th offense |
Cooldown between punishment cycles prevents overlap:
cooldown = min(longestActiveDuration + 3000 ms, 600000 ms)
Max cooldown is 10 minutes. During cooldown, violations are counted but no new punishment starts.
- Hardware Beep: 3 beeps at 1000 Hz / 500 ms, 200 ms gap between (
PunishmentManager.cpp:445-448). Minimum duration floor: 2,000 ms. - Flash: Toggle overlay alpha between 5↔45 every 150 ms for the configured duration.
- Drunk Mouse: Random offset ±80 px on both axes, applied every 50 ms (
uniform_int_distribution<>(-80, 80)). - Pledge Breach: Fixed 2-hour black screen (
7,200,000 ms), cannot be bypassed except via EMERGENCY_BYPASS.
See docs/ARCHITECTURE.md for exact per-punishment thread management and cleanup sequences.
A secret 10-character code provides emergency escape. The code is never stored in plaintext — only its SHA-256 hash exists on disk.
On every key-down event (digits and letters only, lowercase-converted), the last 10 characters of m_bypassBuffer are SHA-256 hashed and compared against m_bypassHash:
// Pseudocode of the check (KeyboardHook.cpp:221-234)
if (bypassBuffer.length >= 10) {
tail = bypassBuffer.last(10);
tailHash = SHA256(tail);
if (tailHash == m_bypassHash) {
EraseLastChars(10); // inject 10 Backspace key events
fire m_onBypass;
}
}- Buffer truncation: at 50 chars, keep last 30
- Key filtering: only
0x30-0x39(digits) and0x41-0x5A(A-Z) pass through; modifiers, arrows, punctuation, and injected keys are all filtered - English keyboard only (
primaryLang == 0x0009); Ctrl-held keys bypass the hook entirely
Sent over IPC as EMERGENCY_BYPASS:<sha256(value)>. The engine hashes the submitted value and compares against g_consoleBypassHash. On match:
StopAllPunishments()SetKeyboardFrozen(false)- Re-initialize
PunishmentManager(reset all state) - Delete
oath.state - Show "UNLOCKED" overlay for 3 seconds
Both hashes must be set in C:\ProgramData\Oathkeeper\bypass_config.json (keys "keyboard_bypass" and "console_bypass") before the bypass mechanism is usable. The repository contains no default hash values.
Open question, more important than it looks: confirm exactly how these values get into
bypass_config.jsonon a real install. There's a real difference between: (a) the installer/first-run flow generates a fresh random value per install and forces the user to set their own code, versus (b) every build embeds the same default constant, and this file just isn't shown in the public repo. Only (a) actually fixes the original finding that the bypass secret was a fixed, extractable constant. If it's (b), the secret is still fixed and extractable — it's just no longer visible in the docs, which is a documentation fix, not a security fix. State whichever is actually true here once confirmed, then remove this note.
The UI communicates with the engine over a Windows Named Pipe at \\.\pipe\OathkeeperPipe. The pipe ACL grants GENERIC_ALL to SYSTEM and Administrators only (IPCServer.cpp). Elevated processes and SYSTEM can connect; non-elevated processes are denied access.
Commands are sent as JSON with "command" and "value" keys, newline-delimited, read 1 byte at a time by the engine. Responses are newline-terminated strings.
| Command | Value Format | Action |
|---|---|---|
BLOCK_WEBSITE |
domain.com |
Add to NetworkFilter hosts blocklist |
UNBLOCK_WEBSITE |
domain.com |
Remove from NetworkFilter |
BLOCK_PROCESS |
process.exe |
Add to WindowMonitor blocklist |
UNBLOCK_PROCESS |
process.exe |
Remove from WindowMonitor |
BLOCK_KEYWORD |
keyword |
Add to WindowMonitor + KeyboardHook |
UNBLOCK_KEYWORD |
keyword |
Remove from WindowMonitor + KeyboardHook |
BLOCK_WEBSITE_AS_KEYWORD |
domain.com |
Clean domain, add to WindowMonitor only |
UNBLOCK_WEBSITE_AS_KEYWORD |
domain.com |
Remove from WindowMonitor |
APPLY_SETTINGS |
JSON payload | Bulk toggle punishments via string search |
TOGGLE_ADULT_SHIELD |
true/false |
Enable/disable 10k adult domain list |
TOGGLE_PUNISHMENT |
type:on/off |
Toggle individual punishment |
SET_EMAIL |
`recipient | sender |
SET_ACCOUNTABILITY_PARTNER |
message` | |
SET_AUTO_APOLOGY |
`target | message` |
START_PLEDGE |
— | No-op (returns ok) |
PLEDGE_BREACH |
— | Execute 2-hour black screen in new thread |
EMERGENCY_STOP |
— | Sets g_running = false, signals stop |
RESTART_INTERNET |
— | Re-enable all 6 network interfaces |
EMERGENCY_BYPASS |
sha256(value) |
SHA-256 hash check against console bypass |
CONFIRM_APOLOGY |
— | Sets m_apologyConfirmed = true |
GET_PUNISHMENT_COUNTS |
— | Returns all offense counters as JSON |
ADD_SMART_BLOCK |
`proc | blockedTitles |
REMOVE_SMART_BLOCK |
process.exe |
Remove smart block by process |
NOTIFY_EMAIL |
`subject | body` |
GET_STATUS |
— | Returns all punishment enabled states |
Website blocking uses the Windows hosts file at C:\Windows\System32\drivers\etc\hosts:
# === Oathkeeper Blocklist ===
127.0.0.1 example.com
127.0.0.1 www.example.com
127.0.0.1 blocked2.com
127.0.0.1 www.blocked2.com
...
# === End Oathkeeper Blocklist ===
Each domain gets two entries (bare + www.), both resolving to 127.0.0.1.
The NetworkFilter thread verifies the hosts file every 30 seconds (NetworkFilter.cpp:131). If the sentinel line # === Oathkeeper Blocklist === is missing (e.g., user or AV removed it), the blocklist is re-applied.
Three-tier fallback strategy (NetworkFilter.cpp:184-197):
- Load
dnsapi.dlldynamically → callDnsFlushResolverCacheviaGetProcAddress - If that fails →
ipconfig /flushdnsviasystem() - If
dnsapi.dllwon't load → samesystem()fallback
The hosts file is also backed up on first modification and restored when Stop() is called (NetworkFilter.cpp:155-164, 200-209).
Optional 10,000+ domain list loaded from C:\ProgramData\Oathkeeper\data\adult_domains.txt. Added to the hosts file blocklist when toggled on via TOGGLE_ADULT_SHIELD.
The engine reads expiry_timestamp from C:\ProgramData\Oathkeeper\oath.state and compares it against the current system time ( UTC — confirm GetSystemTime is used, not GetLocalTime; a prior audit found a local/UTC mismatch here that should have been fixed. Once confirmed, remove this note and state "UTC" plainly.) every 30 seconds (main.cpp:541-569). The tolerance window is 1 day past the expiry date:
// Violation condition (simplified)
current > expiry + 1 dayIf detected: force-enables screenFreeze + hardwareBeep and fires a "TIME MANIPULATION" punishment.
oath.state stores: expiry timestamp, offense counters, active pledge status. Saved every 60 seconds. On startup, the engine checks for a prior state — if found, it resumes enforcement immediately.
The engine registers itself under both SafeBoot modes (main.cpp:617-646):
HKLM\SYSTEM\CurrentControlSet\Control\SafeBoot\Minimal\OathkeeperEngine→REG_SZ "Service"HKLM\SYSTEM\CurrentControlSet\Control\SafeBoot\Network\OathkeeperEngine→REG_SZ "Service"
This ensures the enforcement engine starts even in Safe Mode.
Email credentials are encrypted with Windows DPAPI (CryptProtectData, CRYPTPROTECT_UI_FORBIDDEN, desc: L"OathkeeperEmail") and stored locally. The encryption is machine-specific — the state file cannot be decrypted on another machine.
| Job | Arch | Output |
|---|---|---|
| Engine | x64 / x86 / arm64 | oathkeeper_srv.exe |
| UI | x64 / x86 | Oathkeeper.exe + DLLs |
| Installer | x64 | MSI + EXE |
CI runs on every push via GitHub Actions — see build.yml.
- docs/ARCHITECTURE.md — Full architecture with diagrams
- docs/MODULES.md — Per-file documentation
- KNOWN_ISSUES.md — Known issues and workarounds
- SECURITY.md — Security policy and vulnerability reporting
- CONTRIBUTING.md — How to contribute
- TESTING.md — Testing status and manual test checklist
- CODE_OF_CONDUCT.md — Community guidelines
GNU General Public License v3.0 — see LICENSE.