From 1275b1baaa2ef2dca6a3a8254d021a174eb60bc3 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Wed, 14 Oct 2026 15:00:22 +1100 Subject: [PATCH] Phase 4: kids' Windows computer client (AUD-97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds windows/, the Python client for the kids' Windows computers, and proves both halves of the Phase 4 definition of done on the real box (DESKTOP-TE2RHV2). The client: - subscribes, retained, to its own config on the 8883 TLS device plane with its own broker account (FR-3, FR-6); - keeps the last valid config on disk and loads it before connecting, so a broker outage changes nothing (FR-14); - terminates every process on the blocklist while the device is effectively locked, with the allowlist winning and the client's own tree plus critical system processes protected (FR-4, FR-16); - publishes presence and daily per-app usage; the day's totals are held on disk and republished when the link returns (FR-7); - shows an always-on-top warning window naming what is blocked, why and until when (FR-9). install-scheduled-task.ps1 registers the logon Scheduled Task for the kid's account with an interactive token, and grants that account Modify on the state directory: a file created from an elevated shell inherits Administrators only, and the limited task is otherwise refused with Access denied. Evidence (windows/EVIDENCE.md, from the machine's own log): notepad.exe killed inside a scheduled window (pid 16600, then 9724), the device online in the Control Service with locked/until/reason, a telemetry row for the device (app=690s), enforcement continuing with the broker unreachable (192.168.20.13:8884), the warning window on the interactive desktop, and the Scheduled Task registered at logon as New Comp. Domains are deliberately not reported: Windows has no cheap per-process DNS attribution, so the client sends "domains": [] and the gap is documented rather than guessed. 54 unit tests, run with `make windows-test` (no broker, no Windows, no psutil). AGENTS.md §11 is now 10 of 10. --- AGENTS.md | 5 +- Makefile | 8 +- README.md | 52 +++- windows/EVIDENCE.md | 199 +++++++++++++++ windows/README.md | 231 +++++++++++++++++ windows/install-scheduled-task.ps1 | 90 +++++++ windows/requirements.txt | 9 + windows/tests/__init__.py | 9 + windows/tests/test_blocker.py | 79 ++++++ windows/tests/test_config.py | 149 +++++++++++ windows/tests/test_settings_and_link.py | 102 ++++++++ windows/tests/test_telemetry.py | 115 +++++++++ windows/webrestrict/__init__.py | 12 + windows/webrestrict/__main__.py | 96 +++++++ windows/webrestrict/agent.py | 317 ++++++++++++++++++++++++ windows/webrestrict/blocker.py | 87 +++++++ windows/webrestrict/config.py | 268 ++++++++++++++++++++ windows/webrestrict/mqtt_link.py | 241 ++++++++++++++++++ windows/webrestrict/overlay.py | 147 +++++++++++ windows/webrestrict/process_watch.py | 263 ++++++++++++++++++++ windows/webrestrict/settings.py | 178 +++++++++++++ windows/webrestrict/telemetry.py | 155 ++++++++++++ 22 files changed, 2799 insertions(+), 13 deletions(-) create mode 100644 windows/EVIDENCE.md create mode 100644 windows/README.md create mode 100644 windows/install-scheduled-task.ps1 create mode 100644 windows/requirements.txt create mode 100644 windows/tests/__init__.py create mode 100644 windows/tests/test_blocker.py create mode 100644 windows/tests/test_config.py create mode 100644 windows/tests/test_settings_and_link.py create mode 100644 windows/tests/test_telemetry.py create mode 100644 windows/webrestrict/__init__.py create mode 100644 windows/webrestrict/__main__.py create mode 100644 windows/webrestrict/agent.py create mode 100644 windows/webrestrict/blocker.py create mode 100644 windows/webrestrict/config.py create mode 100644 windows/webrestrict/mqtt_link.py create mode 100644 windows/webrestrict/overlay.py create mode 100644 windows/webrestrict/process_watch.py create mode 100644 windows/webrestrict/settings.py create mode 100644 windows/webrestrict/telemetry.py diff --git a/AGENTS.md b/AGENTS.md index 4b11c4f..ec47e57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,14 +189,15 @@ The Hub interface is frozen in `central_ai_llm_master/docs/ask-contract.md`. ## 11. Build order -**STATUS — 9 of 10 done — only Phase 4 outstanding (checked 2026-10-14).** Keep +**STATUS — 10 of 10 done — Phase 4 passed its DoD on the real Windows box +(checked 2026-10-14).** Keep this list current: tick a box when its DoD passes. **The project is DONE only when every box is ticked.** - [x] Phase 1 — Skeleton - [x] Phase 2 — Devices and state - [x] Phase 3 — MQTT -- [ ] **Phase 4 — Computer client (Windows)** ← REMAINING +- [x] Phase 4 — Computer client (Windows) - [x] Phase 5 — Phone client (no-wipe) - [x] Phase 6 — Schedules, blocklists, allowlists - [x] Phase 7 — Telemetry and usage diff --git a/Makefile b/Makefile index 673e618..1d0936d 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ MQTT_VERIFY_PORT ?= 18883 MQTT_IMAGE ?= eclipse-mosquitto:latest .PHONY: help test vet build up down restart logs ps hash-password deps fmt \ - mqtt-tls mqtt-verify mqtt-config verify-phase8 + mqtt-tls mqtt-verify mqtt-config verify-phase8 verify-phase9 windows-test help: @echo "make test run go test ./... in golang:1.25-alpine" @@ -28,6 +28,7 @@ help: @echo "make mqtt-tls generate the lab CA and broker certificate" @echo "make mqtt-config build the scratch broker config in deploy/mosquitto/build/" @echo "make mqtt-verify prove TLS + credentials + topic ACLs on a scratch broker" + @echo "make windows-test run the kids' Windows client unit tests (python -m unittest)" @echo "make verify-phase8 prove an API lock reaches a device in under a second" @echo "make verify-phase9 prove the Hub answers and the Apprise alerts deliver" @@ -79,6 +80,11 @@ mqtt-verify: mqtt-config MQTT_VERIFY_PORT=$(MQTT_VERIFY_PORT) MQTT_IMAGE=$(MQTT_IMAGE) \ deploy/mosquitto/verify-isolation.sh +# --- Windows client (Phase 4) ------------------------------------------ +# Pure logic only: no broker, no Windows, and no psutil needed. +windows-test: + cd windows && python3 -m unittest discover -s tests -t . + # Phase 8 definition of done: lock through the admin API, measured until a real # broker subscriber receives the retained config. Never touches the 1883 plane. verify-phase8: diff --git a/README.md b/README.md index f394d83..ce8a219 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists, per-device state and usage, and serves the web dashboard. Only it publishes device config. -**Project status: 9 of 10 phases done.** Phases 1–3 and 5–10 are complete (Phase 10, -this documentation set, included). **Phase 4 — the Windows computer client — is -deliberately deferred and is NOT built.** There is no Python computer client and -no Windows process blocking in this repository. The only enforcement client is -the kids' Android app in `android/`. See `AGENTS.md` §11. +**Project status: 10 of 10 phases done.** Phases 1–10 are complete, including +**Phase 4 — the kids' Windows computer client** in `windows/` (Python, MQTT +config, process blocking, telemetry, warning overlay, keep-last-state, installed +as a logon Scheduled Task). Evidence: `windows/EVIDENCE.md`. The enforcement +clients are the kids' Android app in `android/` and the Windows client in +`windows/`. See `AGENTS.md` §11. -This repository contains **Phases 1–3 and 5–10** (AGENTS.md §11): +This repository contains **Phases 1–10** (AGENTS.md §11): - Go service with its own image (`Dockerfile`) and compose file. - SQLite schema for the whole data model (§7), applied through versioned @@ -539,6 +540,32 @@ cache in `.gocache/` (gitignored), so nothing in the repository ends up owned by root. `make mqtt-verify` never touches the shared Mosquitto: it stages a scratch broker on `127.0.0.1:18883` and removes it afterwards. +## Kids' Windows computer client (Phase 4) + +`windows/` is the kids' computer client: Python, one process, installed as a +Scheduled Task at logon. It subscribes, retained, to its own config on the 8883 +device plane, and while the device is locked it terminates any process on the +blocklist that is not on the allowlist (FR-3, FR-4). + +- **Keep last state (FR-14).** The last valid config is cached on disk and loaded + before the first connect, so a broker outage changes nothing. +- **Presence (FR-6).** A retained `{"online": true}` status plus the broker's + Last Will `{"online": false}`; a force-stop or a power-off shows up as an + offline device. +- **Usage (FR-7).** Daily per-app seconds, sampled from the apps that own a + visible window, published as day totals on the device's telemetry topic. +- **Warning (FR-9).** An always-on-top Tkinter window naming what is blocked, why + and until when. +- **Domains are not reported.** Windows has no cheap per-process DNS + attribution, so the client sends `"domains": []` rather than a guess; there is + no Windows site filter in this phase. + +`windows/EVIDENCE.md` holds the transcript of the Definition of Done on the real +machine: a listed process killed inside a scheduled window, the device online in +this service, a telemetry row for it, enforcement continuing with the broker +unreachable, and the Scheduled Task registered at logon. Run the client's tests +with `make windows-test`. + ## Kids' Android client (Phase 5.1) `android/` is the kids' phone app: a normal Gradle project with its own @@ -575,6 +602,10 @@ emulator on `.27`; see `android/README.md` and `android/tools/emu-proof.sh`. ``` cmd/control/ service entry point (+ hash-password, mqtt-clients, rollup, keycode, alerts, usage) +windows/ kids' Windows computer client (Python; process block, overlay, keep-last-state) + webrestrict/ MQTT link, block rule, enforcer, telemetry, warning window, agent loop + install-scheduled-task.ps1 logon Scheduled Task (+ the state directory ACL) + EVIDENCE.md the Phase 4 proof transcript from the real machine android/ kids' Android client (Gradle, Kotlin; Accessibility + overlay) app/src/main/java/… Accessibility Service, overlay, block rule, MQTT, tamper report tools/emu-proof.sh headless emulator proof (block overlay + tamper) @@ -625,14 +656,15 @@ docs/vikunja-tasks.json copy of the Vikunja board tasks ## Non-goals (Phase 10) -- **Phase 4, the Windows computer client, is not built.** It is deliberate. The - Python client, process blocking, and the Windows overlay warning do not exist. - The project stands at **9 of 10** because of this single gap. +- **The Windows client does not filter sites.** Blocking is by process only. There + is no per-process DNS attribution on Windows without a second dependency or an + administrator-only channel, so the client publishes `"domains": []`; FR-18 site + filtering is satisfied on the phone only (`android/`). See `windows/README.md`. - No grant button in the dashboard. Grant is on the admin API; the dashboard has lock and unlock. Adding the button is a later UI change. - No per-child or per-device keycode. One code, every device (§10 rule 3). - No HTTP telemetry ingest. Telemetry arrives over MQTT only. The kids' phone - client exists (Phase 5). The kids' computer client does not (Phase 4, deferred). + client exists (Phase 5) and the kids' Windows client exists (Phase 4). - No Alerts screen in the dashboard yet. The alert history is in `alerts` and is readable with `control alerts`; the screen is a later UI change. - The Hub is wired to this service: the running Hub on `.13:8788` routes diff --git a/windows/EVIDENCE.md b/windows/EVIDENCE.md new file mode 100644 index 0000000..d31f1fa --- /dev/null +++ b/windows/EVIDENCE.md @@ -0,0 +1,199 @@ +# Phase 4 evidence — Windows client on the real machine + +**Date:** 2026-10-14 (Australia/Sydney, UTC+11) +**Machine:** `DESKTOP-TE2RHV2` (`192.168.20.31`), account `New Comp` (local administrator, task +runs with a limited interactive token) +**Client version:** `webrestrict-windows 1.0.0-phase4` (`windows/`) +**Device:** `Finn's computer`, Control Service device id 2, topic `restrict/finn_s_computer_2/config` +**Broker:** Mosquitto on `.13:8883`, TLS, account `finn_s_computer_2` (topic ACL, `%u`) +**Control Service:** `.13:8791`, dashboard `https://webrestrict.lab.audasmedia.com.au` + +The Definition of Done (AGENTS.md §11 Phase 4): + +1. a real Windows machine blocks a listed process during a scheduled window; +2. the client keeps its last state when the broker is unavailable. + +Both are shown below from the machine's own log. The client was installed as a Scheduled Task +and started through that task, so the evidence is produced by the deployment, not by a +hand-run interpreter. + +--- + +## 1. The configuration that caused the block + +Set through the dashboard (device 2): blocklist entry `notepad.exe`, and a temporary weekly +window for Wednesday 14:00–23:59. The Control Service published the retained config, read back +from the broker with the device's own account: + +```json +{"v":1,"device_id":2,"name":"Finn's computer","platform":"windows","locked":true, + "until":"2026-10-14T23:59:00+11:00","allow":[],"block":["notepad.exe"], + "reason":"schedule","generated_at":"2026-10-14T03:49:31.401827999Z"} +``` + +## 2. A listed process is killed inside the scheduled window + +Before the client starts, the process is running on the interactive desktop: + +``` +Image Name PID Session Name Session# Mem Usage +========================= ======== ================ =========== ============ +notepad.exe 16600 Console 2 13,396 K +INFO: No tasks are running which match the specified criteria. <- pythonw.exe: client not running +``` + +The Scheduled Task is started (`schtasks /Run /TN "WebRestrict Kids Client"`). Machine log +`C:\ProgramData\WebRestrict\state\client.log`: + +``` +2026-10-14 14:59:46,301 INFO webrestrict no cached config yet; seeded an unlocked config (FR-14) +2026-10-14 14:59:46,301 INFO webrestrict startup config locked=False until=None block=0 allow=0 cached_at=None +2026-10-14 14:59:46,414 INFO webrestrict.overlay warning window is available +2026-10-14 14:59:46,498 INFO webrestrict.mqtt mqtt connecting to 192.168.20.13:8883 as finn_s_computer_2 (topic restrict/finn_s_computer_2/config) +2026-10-14 14:59:46,500 INFO webrestrict client running: poll=5s telemetry=30s status=30s device=finn_s_computer_2 state_dir=C:\ProgramData\WebRestrict\state +2026-10-14 14:59:47,131 INFO webrestrict.mqtt mqtt connected, subscribing to restrict/finn_s_computer_2/config +2026-10-14 14:59:47,131 INFO webrestrict link up: publishing presence and today's usage +2026-10-14 14:59:47,140 INFO webrestrict config applied locked=True until=2026-10-14T23:59:00+11:00 reason=schedule block=['notepad.exe'] allow=[] generated_at=2026-10-14T03:49:31.401827999Z +2026-10-14 14:59:47,169 INFO webrestrict blocked notepad.exe (pid=16600, rule=notepad): terminated +2026-10-14 14:59:47,169 INFO webrestrict enforcement killed 1 process(es): notepad.exe(16600) +``` + +After, on the same machine: + +``` +INFO: No tasks are running which match the specified criteria. <- notepad.exe, gone +Image Name PID Session Name Session# Mem Usage +pythonw.exe 12608 Console 2 47,904 K +``` + +The cached config on disk, which is what a broker-less restart uses: + +```json +{"allow": [], "block": ["notepad.exe"], "device_id": 2, + "generated_at": "2026-10-14T03:49:31.401827999Z", "grant_until": null, "locked": true, + "name": "Finn's computer", "platform": "windows", "reason": "schedule", + "until": "2026-10-14T23:59:00+11:00", "v": 1} +``` + +## 3. The dashboard shows the device online with the right state + +`GET /api/v1/admin/devices/2` on `.13:8791` while the client was running: + +```json +{"ok":true,"device":{"id":2,"name":"Finn's computer","child":"Finn","platform":"windows", + "topic":"restrict/finn_s_computer_2/config","online":true,"last_seen":"2026-10-14T04:01:17Z", + "locked":true,"until":"2026-10-14T23:59:00+11:00","grant_until":null,"reason":"schedule"}} +``` + +`online: true` comes from the client's retained status report; `locked`/`until`/`reason` are the +schedule that is in force. The machine log shows the presence heartbeat: + +``` +2026-10-14 15:00:17,228 INFO webrestrict status published locked=True blocking=True broker=connected cached_config=True +2026-10-14 15:00:47,323 INFO webrestrict telemetry day=2026-10-14 apps=10 top=[{"name": "ApplicationFrameHost.exe", "seconds": 30}, ...] +2026-10-14 15:01:17,417 INFO webrestrict telemetry day=2026-10-14 apps=10 top=[{"name": "ApplicationFrameHost.exe", "seconds": 60}, ...] +``` + +## 4. A telemetry row lands in the Control Service + +Control Service, same day (`docker exec webrestrict-control /app/control usage`): + +``` +day=2026-10-14 source=rollup +Harry's phone Harry app=5466 domain=4920 screen=5466 +Finn's computer Finn app=690 domain=0 screen=690 +total_screen=6156 +``` + +`app=690` is `Finn's computer`'s daily total in seconds, from the client's +`restrict/finn_s_computer_2/telemetry` reports. `domain=0` is the documented gap: Windows has no +cheap per-process DNS attribution, so the client sends `"domains": []` instead of a guess (see +`README.md`). + +The on-device roll-up shows the attribution, and confirms the client no longer credits itself +(the overlay's own window is excluded): + +```json +{"days": {"2026-10-14": {"ApplicationFrameHost.exe": 60, "Discord.exe": 60, "EADesktop.exe": 60, + "Overwolf.exe": 60, "Spotify.exe": 60, "TextInputHost.exe": 60, "chrome.exe": 60, "cmd.exe": 60, + "explorer.exe": 60, "powershell.exe": 60}}} +``` + +## 5. Keep last state when the broker is unavailable (FR-14) + +The client was stopped, then started again with a copy of the config whose broker port points at +a dead listener (`192.168.20.13:8884`) and the **same** state directory, so it loads the cached +config. A new `notepad.exe` was started afterwards. Machine log: + +``` +2026-10-14 15:01:41,347 INFO webrestrict startup config locked=True until=2026-10-14T23:59:00+11:00 block=1 allow=0 cached_at=2026-10-14T03:49:31.401827999Z +2026-10-14 15:01:41,539 INFO webrestrict.mqtt mqtt connecting to 192.168.20.13:8884 as finn_s_computer_2 (topic restrict/finn_s_computer_2/config) +2026-10-14 15:01:47,045 WARNING webrestrict.mqtt broker 192.168.20.13:8884 is unreachable; enforcing the cached config only (FR-14) +2026-10-14 15:01:47,045 WARNING webrestrict broker link lost (broker unreachable): enforcing the cached config (FR-14) block=['notepad.exe'] until=2026-10-14T23:59:00+11:00 +2026-10-14 15:01:52,207 INFO webrestrict blocked notepad.exe (pid=9724, rule=notepad): terminated +``` + +`notepad.exe` (PID 9724) was running in session 2 immediately before that line, and +`tasklist /FI "IMAGENAME eq notepad.exe"` reported no matching task afterwards. The 8884 listener +does not exist, so nothing was received from the broker during that run. + +The same resilience covers usage: on the next connected start the client republished the day's +totals held on disk (`telemetry republished day=2026-10-14 apps=10`). + +## 6. The warning window (FR-9) + +The client runs in the child's session (`pythonw.exe`, session 2) and shows an always-on-top +Tkinter window. A window enumeration from the same session, while a block was in force: + +```json +{"hwnd": 3278938, "visible": true, "titlelen": 11, "title": "Screen time", "pid": 9908, + "class": "TkTopLevel", "proc": "pythonw.exe"} +``` + +Screenshot of the interactive desktop taken by a helper task in the same session: +`screen-offline-run.png` (attached to AUD-97). The bottom-right region of the image is a large +light-grey panel over a dark desktop — the overlay, placed bottom-right of the primary screen. + +## 7. The Scheduled Task + +``` +Folder: \ +HostName: DESKTOP-TE2RHV2 +TaskName: \WebRestrict Kids Client +Status: Ready +Logon Mode: Interactive only +Run As User: New Comp +Task To Run: C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe -m webrestrict --config "C:\ProgramData\WebRestrict\client.json" +Start In: C:\ProgramData\WebRestrict\client +Schedule Type: At logon time +``` + +Installed by `install-scheduled-task.ps1` (which also grants the account Modify on the state +directory), or by the one-line `schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ...` +form in `README.md`. + +Every step above was produced by starting this task (`schtasks /Run`), not by a shell. + +## 8. What was not proven, and the honest gaps + +* **Domains in telemetry.** Not implemented on Windows, by design and by decision. The client + publishes `"domains": []`; there is no FR-18 site filter for Windows in this phase. +* **Blocking another account's processes.** The task runs with a limited (non-elevated) token, so + it blocks the child's own processes. A process owned by a different Windows account would need + the task to run at highest privilege. +* **Tamper detection is presence, not a watchdog.** A force-stop or a logoff drives the broker's + Last Will to `{"online": false}`, which the Control Service turns into the offline alert + (FR-13). There is no separate on-device watchdog process; a determined child can end + `pythonw.exe` and is visible as an offline device seconds later. +* **A process started mid-window is killed on the next tick** (≤ `poll_seconds`, 5 s by default). + +## 9. End state left on the machine + +* `WebRestrict Kids Client` is registered (at logon, `New Comp`, interactive) and running. +* The temporary proof window (Wednesday 14:00–23:59) was **cleared**; no invented schedule is in + force. `notepad.exe` stays on the blocklist, so the rule is ready for the next window — it does + nothing while the device is unlocked. +* Proof scaffolding (helper tasks, helper scripts, the offline config copy, screenshots) was + removed from the machine. Only `C:\ProgramData\WebRestrict\{client,client.json,ca.crt,state}` + remains. +* Final device state: `online: true`, `locked: false`, `reason: ""`. diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..df25be9 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,231 @@ +# Kids' Windows computer client — Phase 4 + +The kids' **Windows** client (AGENTS.md §11 Phase 4, FR-3, FR-4, FR-6, FR-7, +FR-9, FR-14). Python, one process, no service install and no admin requirement +beyond what the account already has. It follows the Control Service's desired +state over MQTT, blocks listed processes while the device is locked, warns the +child, reports usage, and **keeps its last state when the broker is +unreachable**. + +It is a device, not a control plane: it never changes its own rules (AGENTS.md +§10 rule 7). Everything it enforces arrives on `restrict//config`. + +## What it does + +| Piece | File | Job | +|---|---|---| +| Config transport | `webrestrict/mqtt_link.py` | Subscribes, retained, to `restrict//config` on the 8883 TLS plane with the device's own credentials; publishes presence and usage on `restrict//status` and `restrict//telemetry`. | +| Desired state | `webrestrict/config.py` | Parses the Phase 3 payload and computes the effective lock (`locked` and the clock). | +| Last state (FR-14) | `webrestrict/config.py` | The last valid config is cached on disk; a broker outage changes nothing, and only a newer valid config replaces it. | +| Block rule | `webrestrict/blocker.py` | Blocked = effectively locked **and** on the blocklist **and** not allowlisted (FR-4). | +| Process blocking (FR-3) | `webrestrict/process_watch.py` | Polls `psutil`, `terminate()`s a blocked process, escalates to `kill()` after a short grace. | +| Warning (FR-9) | `webrestrict/overlay.py` | An always-on-top Tkinter window: what is blocked, why, until when. | +| Usage (FR-7) | `webrestrict/telemetry.py` | Daily per-app seconds, rolled up on disk and published as day totals. | +| The loop | `webrestrict/agent.py` | Wires the pieces together with bounded, isolated ticks. | +| Install | `install-scheduled-task.ps1` | The logon Scheduled Task. | + +**No service, no driver, no filter at the network edge.** The child can end the +process; that is accepted (see "Honest limits"). + +## How the block works + +Every `poll_seconds` (default 5) the enforcer takes a process snapshot and +selects victims: + +1. the cached config is **not** effectively locked → nothing is blocked; +2. a process is a victim when its normalised name is on the blocklist; +3. the **allowlist always wins** (FR-4) — `spotify.exe` stays up while everything + else is blocked; +4. the client never kills itself, its parent chain, or a protected system + process (`explorer.exe`, `lsass.exe`, `svchost.exe`, …), whatever a blocklist + says. The full default list is `DEFAULT_PROTECTED` in + `webrestrict/settings.py`. + +Name matching is case-insensitive and ignores `.exe`, so `Notepad.EXE`, +`notepad.exe` and `notepad` are the same app. + +"Effectively locked" mirrors `internal/store/schedules.go` `EffectiveState`: the +payload already carries the resolved `locked` flag, so the client only applies +the clock. An expired `until` lifts the block at the same instant the Control +Service would clear it, a live `grant_until` never blocks (FR-10 grant), and an +indefinite lock (`until` absent) never lifts. + +## Keep last state (FR-14) + +The config is cached at `/config-cache.json`, written atomically. The +client loads it **before** it tries to connect, so a device that never reaches +the broker still applies the rules it was last given. A malformed payload, a +newer schema version, or an older `generated_at` is rejected and the cache is +left alone. + +## Presence (FR-6) + +The client publishes a retained `{"online": true}` status when it connects and +registers the will `{"online": false}` on the same topic. If the machine is +switched off, the client is force-stopped, or the link dies, the broker +publishes the will and the Control Service marks the device offline — that is +the Windows tamper/offline signal (FR-17 here is "the client is gone", which +`CONTROL_ALERT_OFFLINE_AFTER` turns into an Apprise alert, FR-13). A clean stop +publishes the offline status explicitly. + +The status payload also carries `locked` and the foreground `app`, so the +dashboard shows real state: + +```json +{"app":"notepad.exe","at":"2026-10-14T04:05:06Z","locked":true,"online":true} +``` + +## Usage telemetry (FR-7), and the domain gap + +Windows has no `UsageStatsManager`. The client builds its own daily total: every +`telemetry_seconds` (default 60) it lists the processes that own a **visible, +titled top-level window** and adds the elapsed seconds to each. The roll-up is +persisted per day (14 days kept) and published as **day totals**, so a repeated +report replaces the Control Service row instead of double counting +(`internal/store/migrations/002_telemetry_unique.sql`): + +```json +{"day":"2026-10-14","apps":[{"name":"notepad.exe","seconds":420}],"domains":[]} +``` + +* It counts "an app had a window on screen", sampled every minute, not Android's + exact foreground clock. It is an approximation, and it is the honest one + available without a second dependency. The client's own process is excluded, + so its warning window is never counted as the child's usage. +* **Domains are always `[]`.** Windows gives no cheap per-process DNS + attribution: it needs a packet capture, an ETW consumer, or the DNS Client + event log — a second dependency or an administrator-only channel. The gap is + recorded here rather than filled with a guess. Site filtering for Windows is + therefore **not** implemented in this phase; FR-18 is satisfied on the phone + only (`android/`). Say so in any status report. + +## Safety limits + +* Never kills a protected system process, the current process tree, or anything + on the allowlist. +* A missing or corrupt config never invents a lock: first run seeds an + **unlocked** config, so a fresh install cannot lock a child out of their + machine while the broker is unreachable. +* Every tick is wrapped: a psutil or Tk failure is logged and the loop + continues. + +## Honest limits + +* The child can end the process (`taskkill /im pythonw.exe`). The enforcement is + the tamper/offline signal and the parent's knowledge, not a fortress + (AGENTS.md §4 FR-17 rationale, TRANSPORT_DESIGN.md "Anti-bypass"). +* A process started **after** a tick is killed on the next tick; the window is + `poll_seconds` (5 s by default). +* The overlay is a warning only. Closing it does not lift the block. +* Running the task as a limited (non-elevated) user blocks the child's own + processes, which is the normal case. Blocking a process owned by **another** + Windows account needs the task to run at highest privilege. + +## Provisioning a machine + +Nothing secret is committed. The config file holds the device's MQTT password. + +1. **Register the device** in the Control Service (dashboard or admin API) with + platform `windows`, then read its topic token: + + ```bash + docker exec webrestrict-control /app/control mqtt-clients + ``` + +2. **Create its broker account** on the 8883 plane and read the password from + the generated file (both gitignored): + + ```bash + deploy/mosquitto/provision-clients.sh + grep -P '^finn_s_computer_2\t' deploy/mosquitto/clients.env + ``` + +3. **Copy the CA and the client** to the machine. `C:\ProgramData\WebRestrict` + is machine-wide and readable by the kid's account: + + ```powershell + New-Item -ItemType Directory -Force C:\ProgramData\WebRestrict\client + # copy windows\webrestrict\* to C:\ProgramData\WebRestrict\client\webrestrict\ + # copy deploy/mosquitto/tls/ca.crt to C:\ProgramData\WebRestrict\ca.crt + python -m pip install --user -r requirements.txt + ``` + +4. **Write `C:\ProgramData\WebRestrict\client.json`** (mode: readable by the kid's + account only) — `{ ` `}` replaced by the real values: + + ```json + { + "broker_host": "192.168.20.13", + "broker_port": 8883, + "ca_file": "C:\\ProgramData\\WebRestrict\\ca.crt", + "device_topic": "restrict/finn_s_computer_2/config", + "mqtt_username": "finn_s_computer_2", + "mqtt_password": "", + "state_dir": "C:\\ProgramData\\WebRestrict\\state" + } + ``` + +5. **Install the Scheduled Task** so the client survives a reboot. The script + also grants the account Modify on the state directory — a file created from + an elevated shell inherits only `Administrators`, and the task's limited + interactive token would otherwise get `Access denied`: + + ```powershell + powershell -ExecutionPolicy Bypass -File C:\ProgramData\WebRestrict\client\install-scheduled-task.ps1 ` + -ClientDir "C:\ProgramData\WebRestrict\client" ` + -Config "C:\ProgramData\WebRestrict\client.json" + ``` + + Or, the one-line form: + + ``` + schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^ + /TR "\"C:\Users\New Comp\AppData\Local\Programs\Python\Python314\pythonw.exe\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\"" + ``` + + Both register the task for the account that runs them, with an interactive + logon token and no stored password, and both start the client at logon. + +6. **Check it without waiting**: + + ``` + python -m webrestrict check + ``` + + That prints the cached config, whether it is effectively locked, and the + processes it would terminate right now. It changes nothing. + +## Running and reading the log + +``` +python -m webrestrict --config C:\ProgramData\WebRestrict\client.json # the client +python -m webrestrict check # one-shot state +python -m webrestrict version +``` + +The client logs to the console and to `/client.log` (2 MB, 3 +rotations). It records every config it applied and every process it blocked, +which is the evidence trail for a parent and for a phase check. + +## Tests + +Pure logic only — no broker, no Windows, no psutil: + +```bash +cd windows && python -m unittest discover -s tests -t . +# or, from the repository root: +make windows-test +``` + +53 tests cover the payload shape, the effective lock, name matching, the +allowlist, the keep-last-state cache, victim selection (including the protected +names), the usage roll-up, and the status and telemetry payloads. + +## Evidence from the live run + +See `windows/EVIDENCE.md` for the transcript of the Definition of Done on the +real machine (`DESKTOP-TE2RHV2`): a listed process killed inside a scheduled +window, the dashboard showing the device online, a telemetry row landing in the +Control Service, enforcement continuing with the broker unreachable, the warning +window on screen, and the Scheduled Task registered at logon. It also records +the honest gaps (no domain attribution, no elevated blocking, tamper = offline). diff --git a/windows/install-scheduled-task.ps1 b/windows/install-scheduled-task.ps1 new file mode 100644 index 0000000..36adb61 --- /dev/null +++ b/windows/install-scheduled-task.ps1 @@ -0,0 +1,90 @@ +# Install (or remove) the Internet Use Control kids' client as a Scheduled Task +# that starts at logon (AGENTS.md §11 Phase 4). +# +# The task runs as the signed-in kid's account, in that account's interactive +# session, which is where the child's apps and the warning window live. No +# password is stored: the task uses an interactive logon token, so it starts +# when the child logs on and the child cannot see a service window. +# +# powershell -ExecutionPolicy Bypass -File install-scheduled-task.ps1 ` +# -ClientDir "C:\ProgramData\WebRestrict\client" ` +# -Config "C:\ProgramData\WebRestrict\client.json" +# +# Remove it again with: +# +# powershell -ExecutionPolicy Bypass -File install-scheduled-task.ps1 -Remove +# +# The equivalent one-line schtasks form (what the phase proof used) is: +# +# schtasks /Create /TN "WebRestrict Kids Client" /SC ONLOGON /F ^ +# /TR "\"\" -m webrestrict --config \"C:\ProgramData\WebRestrict\client.json\"" + +[CmdletBinding()] +param( + [string]$TaskName = "WebRestrict Kids Client", + [string]$Account = "$env:USERDOMAIN\$env:USERNAME", + [string]$Config = "$env:ProgramData\WebRestrict\client.json", + [string]$ClientDir = "$env:ProgramData\WebRestrict\client", + [string]$StateDir = "$env:ProgramData\WebRestrict\state", + [string]$Python = "", + [switch]$SkipAcl, + [switch]$Remove +) + +$ErrorActionPreference = "Stop" + +if ($Remove) { + schtasks /Delete /TN $TaskName /F + Write-Host "removed scheduled task '$TaskName'" + exit 0 +} + +# The state directory holds the log, the cached config and the usage roll-up. +# The task runs with a limited interactive token, so the account needs an +# explicit Modify grant: a file created from an elevated shell inherits only +# Administrators, and the limited task is then refused with "Access denied". +if (-not $SkipAcl) { + if (-not (Test-Path $StateDir)) { New-Item -ItemType Directory -Force -Path $StateDir | Out-Null } + icacls $StateDir /grant "${Account}:(OI)(CI)M" /T /C | Out-Null + Write-Host "granted Modify on $StateDir to $Account" +} + +function Resolve-Python { + param([string]$Explicit) + if ($Explicit) { return $Explicit } + foreach ($candidate in @("pythonw.exe", "python.exe")) { + $found = Get-Command $candidate -ErrorAction SilentlyContinue + if ($found) { + $path = $found.Source + # pythonw.exe has no console, so a logon start shows no window. + $windowless = Join-Path (Split-Path $path) "pythonw.exe" + if (Test-Path $windowless) { return $windowless } + return $path + } + } + throw "python was not found on PATH; pass -Python " +} + +$pythonExe = Resolve-Python -Explicit $Python +$arguments = "-m webrestrict --config `"$Config`"" + +$action = New-ScheduledTaskAction -Execute $pythonExe -Argument $arguments -WorkingDirectory $ClientDir +$trigger = New-ScheduledTaskTrigger -AtLogOn -User $Account +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit (New-TimeSpan -Seconds 0) ` + -MultipleInstances IgnoreNew +$principal = New-ScheduledTaskPrincipal -UserId $Account -LogonType Interactive -RunLevel Limited + +Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` + -Settings $settings -Principal $principal -Force | Out-Null + +Write-Host "registered '$TaskName'" +Write-Host " account : $Account (interactive, at logon)" +Write-Host " command : $pythonExe $arguments" +Write-Host " workdir : $ClientDir" +schtasks /Query /TN $TaskName /V /FO LIST diff --git a/windows/requirements.txt b/windows/requirements.txt new file mode 100644 index 0000000..3d44a11 --- /dev/null +++ b/windows/requirements.txt @@ -0,0 +1,9 @@ +# Windows kids' client dependencies (AGENTS.md §11 Phase 4). +# +# psutil — enumerate and terminate processes. +# paho-mqtt — the MQTT TLS link to the 8883 device plane. +# +# tkinter (the overlay warning) ships with the python.org Windows installer and +# is deliberately not a pip dependency. +paho-mqtt>=2.1,<3 +psutil>=5.9 diff --git a/windows/tests/__init__.py b/windows/tests/__init__.py new file mode 100644 index 0000000..b3171f3 --- /dev/null +++ b/windows/tests/__init__.py @@ -0,0 +1,9 @@ +"""Unit tests for the kids' Windows client (AGENTS.md §11 Phase 4). + +Pure logic only: no broker, no Windows, no psutil. The tests run anywhere with +``python -m unittest`` so the block decision cannot regress unnoticed:: + + cd windows && python -m unittest discover -s tests -t . +""" + +from __future__ import annotations diff --git a/windows/tests/test_blocker.py b/windows/tests/test_blocker.py new file mode 100644 index 0000000..e1cef95 --- /dev/null +++ b/windows/tests/test_blocker.py @@ -0,0 +1,79 @@ +"""Block selection: what the enforcer may and may not kill.""" + +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from webrestrict.blocker import select_victims, self_protection +from webrestrict.config import DeviceConfig + +NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc) + + +def config(**overrides) -> DeviceConfig: + base = dict( + locked=True, + until=(NOW + timedelta(minutes=30)).isoformat(), + block=["notepad.exe", "steam.exe"], + allow=["Spotify.exe"], + ) + base.update(overrides) + return DeviceConfig(**base) + + +PROCESSES = [ + (100, "notepad.exe"), + (101, "Notepad.EXE"), + (200, "steam.exe"), + (300, "Spotify.exe"), + (400, "explorer.exe"), + (500, "python.exe"), +] + + +class SelectVictimsTests(unittest.TestCase): + def test_blocks_every_matching_process(self): + victims = select_victims(PROCESSES, config()) + self.assertEqual([(v.pid, v.matched) for v in victims], [(100, "notepad"), (101, "notepad"), (200, "steam")]) + + def test_allowlist_wins(self): + victims = select_victims(PROCESSES, config(allow=["spotify"])) + self.assertNotIn(300, [v.pid for v in victims]) + + def test_nothing_is_blocked_when_unlocked(self): + self.assertEqual(select_victims(PROCESSES, config(locked=False)), []) + + def test_nothing_is_blocked_when_the_blocklist_is_empty(self): + self.assertEqual(select_victims(PROCESSES, config(block=[])), []) + + def test_nothing_is_blocked_after_until(self): + expired = config(until=(NOW - timedelta(minutes=1)).isoformat()) + self.assertEqual(select_victims(PROCESSES, expired, now=NOW), []) + + def test_protected_names_are_never_victims(self): + victims = select_victims(PROCESSES, config(block=["explorer.exe"]), protected_names=["explorer.exe"]) + self.assertEqual(victims, []) + + def test_protected_pids_are_never_victims(self): + victims = select_victims(PROCESSES, config(block=["python.exe"]), protected_pids=[500]) + self.assertEqual(victims, []) + + def test_duplicate_pids_are_reported_once(self): + victims = select_victims([(100, "notepad.exe"), (100, "notepad.exe")], config()) + self.assertEqual(len(victims), 1) + + def test_bad_rows_are_ignored(self): + victims = select_victims([("x", "notepad.exe"), (0, ""), (None, None)], config()) + self.assertEqual(victims, []) + + +class SelfProtectionTests(unittest.TestCase): + def test_includes_the_process_and_its_parents(self): + pids, names = self_protection(10, ["python.exe", "pythonw.exe"], [5, 3, 1]) + self.assertEqual(pids, {10, 5, 3, 1}) + self.assertEqual(names, {"python.exe", "pythonw.exe"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows/tests/test_config.py b/windows/tests/test_config.py new file mode 100644 index 0000000..a3e053b --- /dev/null +++ b/windows/tests/test_config.py @@ -0,0 +1,149 @@ +"""Config parsing, effective lock state and the keep-last-state cache.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from webrestrict.config import ConfigCache, DeviceConfig, bare, parse_time + +NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc) + + +def payload(**overrides) -> str: + body = { + "v": 1, + "device_id": 2, + "name": "Finn's computer", + "platform": "windows", + "locked": True, + "until": (NOW + timedelta(minutes=30)).isoformat().replace("+00:00", "Z"), + "allow": ["Spotify.exe"], + "block": ["notepad.exe", "steam"], + "reason": "schedule", + "generated_at": "2026-10-14T19:00:00Z", + } + body.update(overrides) + return json.dumps(body) + + +class ParseTests(unittest.TestCase): + def test_parses_the_phase3_shape(self): + config = DeviceConfig.from_json(payload(extra_field="ignored")) + self.assertEqual(config.v, 1) + self.assertEqual(config.device_id, 2) + self.assertTrue(config.locked) + self.assertEqual(config.allow, ["Spotify.exe"]) + self.assertEqual(config.block, ["notepad.exe", "steam"]) + self.assertEqual(config.reason, "schedule") + self.assertIsNotNone(config.until_at()) + + def test_missing_lists_become_empty_arrays(self): + config = DeviceConfig.from_json('{"v":1,"locked":true}') + self.assertEqual(config.allow, []) + self.assertEqual(config.block, []) + + def test_rejects_a_newer_schema(self): + with self.assertRaises(ValueError): + DeviceConfig.from_json(payload(v=2)) + + def test_rejects_malformed_json(self): + with self.assertRaises(json.JSONDecodeError): + DeviceConfig.from_json("{not json") + + def test_parse_time_accepts_z_and_offsets(self): + self.assertEqual(parse_time("2026-10-14T20:00:00Z").tzinfo, timezone.utc) + self.assertEqual(parse_time("2026-10-14T20:00:00+10:00").utcoffset(), timedelta(hours=10)) + self.assertIsNone(parse_time("nonsense")) + self.assertIsNone(parse_time(None)) + + +class EffectiveStateTests(unittest.TestCase): + def test_not_locked_never_blocks(self): + config = DeviceConfig.from_json(payload(locked=False)) + self.assertFalse(config.is_blocking(NOW)) + + def test_locked_without_until_blocks_indefinitely(self): + config = DeviceConfig.from_json(payload(until=None)) + self.assertTrue(config.is_blocking(NOW)) + self.assertTrue(config.is_blocking(NOW + timedelta(days=30))) + + def test_locked_until_a_future_time_blocks(self): + config = DeviceConfig.from_json(payload()) + self.assertTrue(config.is_blocking(NOW)) + + def test_locked_until_a_past_time_does_not_block(self): + config = DeviceConfig.from_json(payload(until=(NOW - timedelta(minutes=1)).isoformat())) + self.assertFalse(config.is_blocking(NOW)) + + def test_a_live_grant_never_blocks(self): + config = DeviceConfig.from_json( + payload(locked=False, until=None, grant_until=(NOW + timedelta(minutes=10)).isoformat()) + ) + self.assertFalse(config.is_blocking(NOW)) + + def test_an_expired_grant_does_not_block_either(self): + config = DeviceConfig.from_json( + payload(locked=False, until=None, grant_until=(NOW - timedelta(minutes=10)).isoformat()) + ) + self.assertFalse(config.is_blocking(NOW)) + + +class NameTests(unittest.TestCase): + def test_bare_normalises_case_and_exe(self): + self.assertEqual(bare("Notepad.EXE"), "notepad") + self.assertEqual(bare(" STEAM.exe "), "steam") + self.assertEqual(bare(None), "") + + def test_blocked_names_subtract_the_allowlist(self): + config = DeviceConfig.from_json(payload(block=["notepad.exe", "Spotify"], allow=["spotify.EXE"])) + self.assertEqual(config.blocked_names(), {"notepad"}) + + def test_allowlist_wins_per_name(self): + config = DeviceConfig.from_json(payload(block=["notepad.exe", "steam.exe"], allow=["Notepad"])) + self.assertFalse(config.is_blocked_name("NOTEPAD.EXE")) + self.assertTrue(config.is_blocked_name("steam.exe")) + + +class CacheTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "config-cache.json" + self.addCleanup(self.tmp.cleanup) + + def test_round_trips_through_disk(self): + cache = ConfigCache(self.path) + stored = cache.accept(payload()) + self.assertIsNotNone(stored) + again = ConfigCache(self.path).load() + self.assertEqual(again.block, stored.block) + self.assertTrue(again.locked) + + def test_a_malformed_payload_keeps_the_last_config(self): + cache = ConfigCache(self.path) + cache.accept(payload()) + self.assertIsNone(cache.accept("{not json")) + self.assertEqual(cache.current.block, ["notepad.exe", "steam"]) + + def test_an_older_config_is_ignored(self): + cache = ConfigCache(self.path) + cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"])) + self.assertIsNone(cache.accept(payload(generated_at="2026-10-14T18:00:00Z", block=["notepad.exe"]))) + self.assertEqual(cache.current.block, ["steam.exe"]) + + def test_a_newer_config_replaces_it(self): + cache = ConfigCache(self.path) + cache.accept(payload(generated_at="2026-10-14T19:30:00Z", block=["steam.exe"])) + stored = cache.accept(payload(generated_at="2026-10-14T19:45:00Z", block=["notepad.exe"])) + self.assertIsNotNone(stored) + self.assertEqual(cache.current.block, ["notepad.exe"]) + + def test_a_missing_file_is_not_an_error(self): + self.assertIsNone(ConfigCache(self.path).load()) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows/tests/test_settings_and_link.py b/windows/tests/test_settings_and_link.py new file mode 100644 index 0000000..1844103 --- /dev/null +++ b/windows/tests/test_settings_and_link.py @@ -0,0 +1,102 @@ +"""Settings loading and the MQTT payload builders (no broker, no paho).""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path + +from webrestrict.mqtt_link import iso_now, status_payload, telemetry_payload +from webrestrict.settings import DEFAULT_PROTECTED, Settings + +RAW = { + "broker_host": "192.168.20.13", + "broker_port": 8883, + "device_topic": "restrict/finn_s_computer_2/config", + "mqtt_username": "finn_s_computer_2", + "mqtt_password": "secret", + "ca_file": "C:/ProgramData/WebRestrict/ca.crt", + "state_dir": "C:/ProgramData/WebRestrict", + "unknown_future_field": "ignored", +} + + +class SettingsTests(unittest.TestCase): + def test_derives_topics_and_token(self): + settings = Settings.from_dict(RAW) + self.assertEqual(settings.device_token, "finn_s_computer_2") + self.assertEqual(settings.status_topic, "restrict/finn_s_computer_2/status") + self.assertEqual(settings.telemetry_topic, "restrict/finn_s_computer_2/telemetry") + self.assertEqual(settings.effective_client_id, "kid-finn_s_computer_2") + + def test_unknown_fields_are_ignored(self): + settings = Settings.from_dict(RAW) + self.assertFalse(hasattr(settings, "unknown_future_field")) + + def test_loads_from_a_file(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "client.json" + path.write_text(json.dumps({**RAW, "state_dir": tmp}), encoding="utf-8") + settings = Settings.load(path) + self.assertEqual(settings.broker_port, 8883) + self.assertEqual(settings.log_path(), Path(tmp) / "client.log") + + def test_validate_rejects_a_bad_topic(self): + settings = Settings.from_dict({**RAW, "device_topic": "restrict/finn"}) + with self.assertRaises(ValueError): + settings.validate() + + def test_validate_rejects_a_missing_account(self): + bad = {k: v for k, v in RAW.items() if k != "mqtt_username"} + with self.assertRaises(ValueError): + Settings.from_dict(bad).validate() + + def test_validate_rejects_a_bad_port(self): + with self.assertRaises(ValueError): + Settings.from_dict({**RAW, "broker_port": 70000}).validate() + + def test_protected_list_covers_the_system_and_the_interpreter(self): + for name in ("explorer", "svchost", "lsass", "python"): + self.assertIn(name, DEFAULT_PROTECTED) + + def test_the_default_config_path_is_overridable(self): + from webrestrict.settings import default_config_path + + previous = os.environ.get("WEBRESTRICT_CONFIG") + os.environ["WEBRESTRICT_CONFIG"] = "/tmp/webrestrict-test.json" + try: + self.assertEqual(str(default_config_path()), "/tmp/webrestrict-test.json") + finally: + if previous is None: + os.environ.pop("WEBRESTRICT_CONFIG", None) + else: + os.environ["WEBRESTRICT_CONFIG"] = previous + + +class PayloadTests(unittest.TestCase): + def test_status_reports_online_and_the_lock(self): + body = json.loads(status_payload(True, "notepad.exe")) + self.assertTrue(body["online"]) + self.assertTrue(body["locked"]) + self.assertEqual(body["app"], "notepad.exe") + self.assertTrue(body["at"].endswith("Z")) + + def test_status_can_carry_a_tamper_report(self): + body = json.loads(status_payload(True, "", tamper={"kind": "client_stopped", "detail": "watchdog"})) + self.assertEqual(body["tamper"]["kind"], "client_stopped") + self.assertTrue(body["tamper"]["at"].endswith("Z")) + + def test_telemetry_shape_is_the_list_form(self): + body = json.loads(telemetry_payload("2026-10-14", {"notepad.exe": 60, "steam.exe": 0})) + self.assertEqual(body["day"], "2026-10-14") + self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 60}]) + self.assertEqual(body["domains"], []) + + def test_iso_now_is_utc(self): + self.assertRegex(iso_now(), r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") + + +if __name__ == "__main__": + unittest.main() diff --git a/windows/tests/test_telemetry.py b/windows/tests/test_telemetry.py new file mode 100644 index 0000000..edadda2 --- /dev/null +++ b/windows/tests/test_telemetry.py @@ -0,0 +1,115 @@ +"""The usage roll-up: payload shape, accumulation, pruning and sampling.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from webrestrict.telemetry import ( + MAX_DAYS, + MAX_SAMPLE_SECONDS, + TelemetryStore, + UsageSampler, + build_payload, + day_key, + to_entries, +) + +NOW = datetime(2026, 10, 14, 20, 0, tzinfo=timezone.utc) + + +class PayloadTests(unittest.TestCase): + def test_shape_matches_the_control_service(self): + body = build_payload("2026-10-14", {"notepad.exe": 120, "steam.exe": 60}) + self.assertEqual(body["day"], "2026-10-14") + self.assertEqual(body["apps"], [{"name": "notepad.exe", "seconds": 120}, {"name": "steam.exe", "seconds": 60}]) + # Windows cannot attribute domains cheaply; the gap is explicit, not guessed. + self.assertEqual(body["domains"], []) + + def test_zero_and_blank_entries_are_dropped(self): + self.assertEqual(to_entries({"": 10, "steam.exe": 0, "notepad.exe": 5}), [{"name": "notepad.exe", "seconds": 5}]) + + def test_day_key_is_local(self): + self.assertEqual(day_key(datetime(2026, 1, 2, 23, 59)), "2026-01-02") + + def test_an_aware_timestamp_is_converted_to_local_time(self): + # The day boundary must be the child's midnight, not UTC's. + offset = datetime.now().astimezone().utcoffset() + utc = datetime(2026, 1, 2, 23, 30, tzinfo=timezone.utc) + self.assertEqual(day_key(utc), (utc + offset).strftime("%Y-%m-%d")) + + +class StoreTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name) / "telemetry.json" + self.addCleanup(self.tmp.cleanup) + + def test_accumulates_and_persists(self): + store = TelemetryStore(self.path) + store.add_seconds("2026-10-14", {"notepad.exe": 60}) + store.add_seconds("2026-10-14", {"notepad.exe": 30, "steam.exe": 10}) + self.assertEqual(store.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10}) + + reloaded = TelemetryStore(self.path) + self.assertEqual(reloaded.day("2026-10-14"), {"notepad.exe": 90, "steam.exe": 10}) + + def test_ignores_non_positive_samples(self): + store = TelemetryStore(self.path) + store.add_seconds("2026-10-14", {"notepad.exe": 0, "steam.exe": -5}) + self.assertEqual(store.day("2026-10-14"), {}) + + def test_prunes_to_the_most_recent_days(self): + store = TelemetryStore(self.path) + for day in range(1, MAX_DAYS + 5): + store.add_seconds(f"2026-01-{day:02d}", {"notepad.exe": 1}) + self.assertEqual(len(store.days), MAX_DAYS) + self.assertIn("2026-01-18", store.days) + self.assertNotIn("2026-01-01", store.days) + + def test_an_unreadable_file_starts_empty(self): + self.path.write_text("{not json", encoding="utf-8") + store = TelemetryStore(self.path).load() + self.assertEqual(store.days, {}) + + def test_written_json_is_readable(self): + store = TelemetryStore(self.path) + store.add_seconds("2026-10-14", {"notepad.exe": 5}) + self.assertIn("days", json.loads(self.path.read_text(encoding="utf-8"))) + + +class SamplerTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.store = TelemetryStore(Path(self.tmp.name) / "telemetry.json") + self.addCleanup(self.tmp.cleanup) + + def test_the_first_sample_only_starts_the_clock(self): + sampler = UsageSampler(self.store) + self.assertEqual(sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14"), {}) + + def test_credits_elapsed_seconds_to_each_visible_app(self): + sampler = UsageSampler(self.store) + sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14") + totals = sampler.sample(["notepad.exe", "steam.exe"], now=NOW + timedelta(seconds=60), day="2026-10-14") + self.assertEqual(totals, {"notepad.exe": 60, "steam.exe": 60}) + + def test_a_long_gap_is_capped(self): + sampler = UsageSampler(self.store) + sampler.sample(["notepad.exe"], now=NOW, day="2026-10-14") + totals = sampler.sample( + ["notepad.exe"], now=NOW + timedelta(hours=5), day="2026-10-14" + ) + self.assertEqual(totals["notepad.exe"], int(MAX_SAMPLE_SECONDS)) + + def test_no_visible_apps_adds_nothing(self): + sampler = UsageSampler(self.store) + sampler.sample([], now=NOW, day="2026-10-14") + self.assertEqual(sampler.sample([], now=NOW + timedelta(seconds=60), day="2026-10-14"), {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/windows/webrestrict/__init__.py b/windows/webrestrict/__init__.py new file mode 100644 index 0000000..0adb0b8 --- /dev/null +++ b/windows/webrestrict/__init__.py @@ -0,0 +1,12 @@ +"""Kids' Windows computer client for Internet Use Control (AGENTS.md §11 Phase 4). + +The client is a device, not a control plane. It subscribes to its own retained +config on the MQTT 8883 plane, blocks listed processes while the device is +locked, reports presence and daily usage, warns the child, and keeps enforcing +its last config when the broker is unreachable (FR-14). It never changes its own +rules (AGENTS.md §10 rule 7). + +Run it as ``python -m webrestrict`` (see ``windows/README.md``). +""" + +__version__ = "1.0.0-phase4" diff --git a/windows/webrestrict/__main__.py b/windows/webrestrict/__main__.py new file mode 100644 index 0000000..f24919f --- /dev/null +++ b/windows/webrestrict/__main__.py @@ -0,0 +1,96 @@ +"""Command line entry point: ``python -m webrestrict`` (AGENTS.md §11 Phase 4). + +Usage:: + + python -m webrestrict run # the client (the Scheduled Task runs this) + python -m webrestrict check # one-shot: cached config, effective state, victims + python -m webrestrict version + +``--config PATH`` overrides the config file (default +``%PROGRAMDATA%\\WebRestrict\\client.json``, or ``WEBRESTRICT_CONFIG``). +""" + +from __future__ import annotations + +import argparse +import json +import logging +import logging.handlers +import sys + +from . import __version__ +from .agent import Agent, check_once, install_signal_handlers +from .settings import Settings, default_config_path + +LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s" + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="webrestrict", description="Kids' Windows computer client") + parser.add_argument("--config", help="path to client.json (default: %(default)s)") + parser.add_argument("--verbose", action="store_true", help="debug logging") + sub = parser.add_subparsers(dest="command") + sub.add_parser("run", help="run the client (default)") + sub.add_parser("check", help="print the cached config and what would be blocked, then exit") + sub.add_parser("version", help="print the client version") + return parser + + +def configure_logging(settings: Settings, verbose: bool) -> None: + """Log to the console and to a rotating file next to the state. + + A Scheduled Task runs with no visible console, so the file is the record + that survives: it holds the config that arrived and every process blocked. + """ + root = logging.getLogger() + root.setLevel(logging.DEBUG if verbose else logging.INFO) + root.handlers.clear() + + console = logging.StreamHandler(sys.stdout) + console.setFormatter(logging.Formatter(LOG_FORMAT)) + root.addHandler(console) + + try: + settings.state_path.mkdir(parents=True, exist_ok=True) + rotating = logging.handlers.RotatingFileHandler( + settings.log_path(), maxBytes=2 * 1024 * 1024, backupCount=3, encoding="utf-8" + ) + rotating.setFormatter(logging.Formatter(LOG_FORMAT)) + root.addHandler(rotating) + except OSError as exc: # pragma: no cover - depends on the machine + root.warning("cannot write the log file %s: %s", settings.log_path(), exc) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + command = args.command or "run" + + if command == "version": + print(f"webrestrict-windows {__version__}") + return 0 + + settings_path = args.config or str(default_config_path()) + try: + settings = Settings.load(settings_path) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"webrestrict: cannot load {settings_path}: {exc}", file=sys.stderr) + return 2 + + configure_logging(settings, args.verbose) + + if command == "check": + print(json.dumps(check_once(settings), indent=2, sort_keys=True)) + return 0 + + agent = Agent(settings) + install_signal_handlers(agent) + try: + return agent.run() + except KeyboardInterrupt: # pragma: no cover - Ctrl-C + agent.shutdown() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/windows/webrestrict/agent.py b/windows/webrestrict/agent.py new file mode 100644 index 0000000..9804cd0 --- /dev/null +++ b/windows/webrestrict/agent.py @@ -0,0 +1,317 @@ +"""The client agent: one loop that enforces, warns, reports and never gives up. + +Wiring, in one place: + +* the cached config is loaded **before** the broker link is attempted, so the + device is enforcing the moment the process starts, broker or no broker + (FR-14); +* the MQTT link subscribes to the retained config and publishes presence; +* every few seconds the enforcer terminates blocked processes while the device + is effectively locked (FR-3, FR-4); +* the overlay shows the child what is blocked and until when (FR-9); +* every minute the usage sample is rolled up and published (FR-7), and the + status report keeps the dashboard's presence fresh (FR-6). + +Nothing here changes the rules. The client obeys and reports (AGENTS.md §10 +rule 7). +""" + +from __future__ import annotations + +import json +import logging +import signal +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone + +from .config import ConfigCache, DeviceConfig +from .mqtt_link import MqttLink, iso_now +from .overlay import Notice, Overlay +from .process_watch import EnforcementReport, Enforcer, foreground_app, visible_apps +from .settings import Settings +from .telemetry import TelemetryStore, UsageSampler, build_payload, day_key + +log = logging.getLogger("webrestrict") + + +def format_until(config: DeviceConfig) -> str: + """A short local rendering of the config's end time, for the log and overlay.""" + for value in (config.grant_until, config.until): + if not value: + continue + from .config import parse_time # noqa: PLC0415 - small helper, local use + + moment = parse_time(value) + return moment.astimezone().strftime("%a %H:%M") if moment else value + return "" + + +@dataclass +class AgentState: + """The moving parts, so tests can drive them without a broker or a desktop.""" + + settings: Settings + cache: ConfigCache + config: DeviceConfig + telemetry: TelemetryStore + sampler: UsageSampler + overlay: Overlay + enforcer: Enforcer + link: MqttLink | None = None + last_report: EnforcementReport | None = None + started_at: float = field(default_factory=time.monotonic) + + +class Agent: + """The long-running kids' client.""" + + def __init__(self, settings: Settings, logger: logging.Logger | None = None): + self.settings = settings + self.log = logger or log + self.stop_event = threading.Event() + telemetry = TelemetryStore(settings.telemetry_path()) + self.state = AgentState( + settings=settings, + cache=ConfigCache(settings.config_path()), + config=DeviceConfig(), + telemetry=telemetry, + sampler=UsageSampler(telemetry), + overlay=Overlay(enabled=settings.overlay), + enforcer=Enforcer(settings.protected, self.log), + ) + + # -- setup ------------------------------------------------------------- + def load_config(self) -> DeviceConfig: + """Load the cached config, seeding a permissive first-run config. + + The seed is **unlocked with no lists**: a client that has never heard + from the broker must never invent a lock. The first retained config + replaces it. + """ + cached = self.state.cache.load() + if cached is None: + cached = DeviceConfig(platform="windows", locked=False) + self.state.cache.save(cached) + self.log.info("no cached config yet; seeded an unlocked config (FR-14)") + self.state.config = cached + self.log.info( + "startup config locked=%s until=%s block=%d allow=%d cached_at=%s", + cached.locked, + cached.until, + len(cached.block), + len(cached.allow), + cached.generated_at, + ) + return cached + + # -- callbacks --------------------------------------------------------- + def apply_config(self, payload: str) -> None: + """Handle a config message: store it if valid, then apply it at once.""" + accepted = self.state.cache.accept(payload) + if accepted is None: + self.log.warning("config ignored (older or unreadable); keeping the cached one") + return + self.state.config = accepted + self.log.info( + "config applied locked=%s until=%s reason=%s block=%s allow=%s generated_at=%s", + accepted.locked, + accepted.until, + accepted.reason or "-", + accepted.block, + accepted.allow, + accepted.generated_at, + ) + # Act on it now instead of waiting for the next tick: a lock must land + # within the poll interval, and the Android client behaves the same way. + self.tick() + + def on_connected(self) -> None: + config = self.state.config + self.log.info("link up: publishing presence and today's usage") + self.publish_status(config) + self.flush_telemetry() + + def on_lost(self, reason: str) -> None: + self.log.warning( + "broker link lost (%s): enforcing the cached config (FR-14) block=%s until=%s", + reason, + self.state.config.block, + self.state.config.until, + ) + + # -- one tick ---------------------------------------------------------- + def tick(self) -> EnforcementReport: + config = self.state.config + now = datetime.now(timezone.utc) + blocking = config.is_blocking(now) + report = self.state.enforcer.enforce(config, now) + self.state.last_report = report + self.state.overlay.update(self.notice(config, report, now) if blocking else None) + return report + + def notice(self, config: DeviceConfig, report: EnforcementReport, now: datetime) -> Notice | None: + if not report.blocked_names: + return None + return Notice( + reason=config.reason_text or "screen-time rules are active", + until=format_until(config), + names=report.blocked_names, + ) + + # -- reporting --------------------------------------------------------- + def publish_status(self, config: DeviceConfig | None = None, app: str | None = None) -> None: + link = self.state.link + if link is None: + return + current = config or self.state.config + link.publish_status(current.is_blocking(), app if app is not None else foreground_app()) + + def flush_telemetry(self) -> None: + link = self.state.link + if link is None: + return + day = day_key() + apps = self.state.telemetry.day(day) + if apps and link.publish_telemetry(day, apps): + self.log.info("telemetry republished day=%s apps=%d", day, len(apps)) + + def sample_usage(self) -> None: + """One usage sample: visible-window apps get the elapsed seconds.""" + apps = visible_apps() + totals = self.state.sampler.sample(apps) + if not totals: + return + day = day_key() + self.log.debug("usage sample day=%s apps=%d", day, len(totals)) + link = self.state.link + if link is not None and link.publish_telemetry(day, totals): + self.log.info( + "telemetry day=%s apps=%d top=%s", + day, + len(totals), + json.dumps(build_payload(day, totals)["apps"][:3]), + ) + + # -- the loop ---------------------------------------------------------- + def run(self) -> int: + self.load_config() + self.state.overlay.start() + + link = MqttLink( + self.settings, + on_config=self.apply_config, + on_connected=self.on_connected, + on_lost=self.on_lost, + ) + self.state.link = link + try: + link.start() + except Exception as exc: # noqa: BLE001 - run without a link, keep the rules + self.log.error("mqtt could not start (%s); enforcing the cached config only", exc) + + self.state.started_at = time.monotonic() + next_telemetry = time.monotonic() + self.settings.telemetry_seconds + next_status = time.monotonic() + self.settings.status_seconds + + self.log.info( + "client running: poll=%ss telemetry=%ss status=%ss device=%s state_dir=%s", + self.settings.poll_seconds, + self.settings.telemetry_seconds, + self.settings.status_seconds, + self.settings.device_token, + self.settings.state_path, + ) + + while not self.stop_event.is_set(): + try: + report = self.tick() + if report.killed: + self.publish_status(self.state.config) + except Exception as exc: # noqa: BLE001 - one bad tick must not stop the client + self.log.exception("enforcement tick failed: %s", exc) + + now = time.monotonic() + if now >= next_telemetry: + next_telemetry = now + self.settings.telemetry_seconds + try: + self.sample_usage() + except Exception as exc: # noqa: BLE001 + self.log.warning("usage sample failed: %s", exc) + if now >= next_status: + next_status = now + self.settings.status_seconds + try: + self.publish_status(self.state.config) + self.log.info( + "status published locked=%s blocking=%s broker=%s cached_config=%s", + self.state.config.locked, + self.state.config.is_blocking(), + "connected" if (self.state.link and self.state.link.connected) else "unreachable", + bool(self.state.config.generated_at), + ) + except Exception as exc: # noqa: BLE001 + self.log.warning("status publish failed: %s", exc) + + self.stop_event.wait(self.settings.poll_seconds) + + self.shutdown() + return 0 + + def shutdown(self) -> None: + self.log.info("client stopping: publishing offline status") + link = self.state.link + if link is not None: + try: + link.publish_offline() + time.sleep(0.2) # let the retained offline message leave + link.stop() + except Exception as exc: # noqa: BLE001 + self.log.debug("shutdown: %s", exc) + self.state.overlay.stop() + + def request_stop(self, *_args) -> None: + self.stop_event.set() + + +def install_signal_handlers(agent: Agent) -> None: + """Stop cleanly on Ctrl-C and on a Scheduled Task 'End task'.""" + for name in ("SIGINT", "SIGTERM"): + handler = getattr(signal, name, None) + if handler is None: + continue + try: + signal.signal(handler, agent.request_stop) + except (ValueError, OSError): # pragma: no cover - not the main thread + pass + + +def check_once(settings: Settings) -> dict[str, object]: + """A one-shot report for the operator and for the phase proof. + + Prints the cached config, the effective block decision, and the processes it + would terminate right now. It changes nothing. + """ + cache = ConfigCache(settings.config_path()) + config = cache.load() or DeviceConfig(platform="windows", locked=False) + enforcer = Enforcer(settings.protected) + from .blocker import select_victims # noqa: PLC0415 - only needed here + from .process_watch import snapshot_processes + + now = datetime.now(timezone.utc) + victims = select_victims( + snapshot_processes(), + config, + now=now, + protected_names=enforcer.protected_names, + protected_pids=enforcer.protected_pids, + ) + return { + "device_topic": settings.device_topic, + "state_dir": str(settings.state_path), + "checked_at": iso_now(), + "config": config.to_dict(), + "blocking": config.is_blocking(now), + "blocked_names": sorted(config.blocked_names()), + "victims": [{"pid": v.pid, "name": v.name, "rule": v.matched} for v in victims], + } diff --git a/windows/webrestrict/blocker.py b/windows/webrestrict/blocker.py new file mode 100644 index 0000000..d3776e4 --- /dev/null +++ b/windows/webrestrict/blocker.py @@ -0,0 +1,87 @@ +"""Pure block-selection logic (AGENTS.md FR-3, FR-4, FR-16). + +No psutil and no Windows here, so the decision can be unit tested anywhere. +:mod:`webrestrict.process_watch` supplies the running process list and does the +killing; this module only decides **which** processes are blocked. + +The rules are deliberately small, because the client must never widen or narrow +them on its own (AGENTS.md §10 rule 7): + +* nothing is blocked while the config is not effectively locked; +* a process is blocked when its normalised name is on the blocklist; +* the allowlist always wins (FR-4); +* the client never kills itself, its parent chain, or a protected system + process, whatever the blocklist says. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable + +from .config import DeviceConfig, bare + + +@dataclass(frozen=True) +class Victim: + """One process that must be terminated.""" + + pid: int + name: str # as reported by the OS, e.g. "notepad.exe" + matched: str # the normalised blocklist entry that matched + + +def select_victims( + processes: Iterable[tuple[int, str]], + config: DeviceConfig, + *, + now: datetime | None = None, + protected_names: Iterable[str] = (), + protected_pids: Iterable[int] = (), +) -> list[Victim]: + """Choose the processes to terminate for one enforcement tick. + + ``processes`` is an iterable of ``(pid, name)`` pairs, so the caller can pass + a psutil snapshot, a fake list in a test, or a single process. The result is + ordered by pid and contains no duplicates, so the caller can log it. + """ + if not config.is_blocking(now): + return [] + + blocked = config.blocked_names() + if not blocked: + return [] + + allowed = config.allowed_names() + protected = {bare(item) for item in protected_names if item and str(item).strip()} + do_not_touch = {int(pid) for pid in protected_pids} + + victims: list[Victim] = [] + seen: set[int] = set() + for pid, name in processes: + try: + pid = int(pid) + except (TypeError, ValueError): + continue + value = bare(name) + if not value or pid in seen: + continue + seen.add(pid) + if pid in do_not_touch or value in protected or value in allowed: + continue + if value in blocked: + victims.append(Victim(pid=pid, name=name, matched=value)) + victims.sort(key=lambda victim: (victim.matched, victim.pid)) + return victims + + +def self_protection(pid: int, image_names: Iterable[str], parents: Iterable[int]) -> tuple[set[int], set[str]]: + """The pids and names the enforcer must never touch for its own process tree. + + The client runs as ``python.exe`` on the kid's machine, so a blocklist that + named Python would otherwise make the client kill itself and stop reporting. + """ + pids = {int(pid)} | {int(parent) for parent in parents} + names = {str(name) for name in image_names if str(name).strip()} + return pids, names diff --git a/windows/webrestrict/config.py b/windows/webrestrict/config.py new file mode 100644 index 0000000..3ec7db5 --- /dev/null +++ b/windows/webrestrict/config.py @@ -0,0 +1,268 @@ +"""The device config and its on-disk cache (AGENTS.md FR-14). + +``restrict//config`` carries the desired state, exactly as +``internal/mqtt/payload.go`` publishes it. This module parses that payload and +keeps the last valid copy on disk, so a device that cannot reach the broker +still knows its rules. Only a valid, not-older config replaces the cache: +a malformed or stale message never leaves the device without rules. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +# The schema version this client understands. A payload with a higher version is +# rejected rather than guessed at; the cached config stays in force. +CONFIG_VERSION = 1 + + +def parse_time(value: str | None) -> datetime | None: + """Parse an RFC 3339 timestamp as the Control Service writes it. + + Accepts a trailing ``Z`` and an optional fractional part. Returns ``None`` + for an empty or unparseable value, so a bad timestamp downgrades to "no end + time" instead of raising. + """ + if not value: + return None + text = value.strip() + if text.endswith("Z") or text.endswith("z"): + text = text[:-1] + "+00:00" + try: + moment = datetime.fromisoformat(text) + except ValueError: + return None + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + return moment + + +def bare(name: str) -> str: + """Normalise a process or list entry for comparison. + + ``notepad.exe``, ``Notepad.EXE`` and ``notepad`` are the same app. The + Windows process list reports ``.exe``; a blocklist entry may or may not. + """ + value = (name or "").strip().casefold() + if value.endswith(".exe"): + value = value[:-4] + return value + + +@dataclass +class DeviceConfig: + """The desired state for one device (TRANSPORT_DESIGN.md, Phase 3).""" + + v: int = CONFIG_VERSION + device_id: int = 0 + name: str = "" + platform: str = "windows" + locked: bool = False + until: str | None = None + allow: list[str] = field(default_factory=list) + block: list[str] = field(default_factory=list) + reason: str = "" + grant_until: str | None = None + generated_at: str | None = None + + # -- parsing ----------------------------------------------------------- + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> "DeviceConfig": + """Parse a payload, ignoring unknown fields (forward compatible). + + Raises ``ValueError`` on a payload this client cannot honour, so the + caller keeps the last good config. + """ + version = raw.get("v", CONFIG_VERSION) + try: + version = int(version) + except (TypeError, ValueError) as exc: + raise ValueError(f"config v is not an integer: {version!r}") from exc + if version > CONFIG_VERSION: + raise ValueError(f"config v={version} is newer than this client ({CONFIG_VERSION})") + + return cls( + v=version, + device_id=int(raw.get("device_id") or 0), + name=str(raw.get("name") or ""), + platform=str(raw.get("platform") or "windows"), + locked=bool(raw.get("locked")), + until=_str_or_none(raw.get("until")), + allow=_string_list(raw.get("allow")), + block=_string_list(raw.get("block")), + reason=str(raw.get("reason") or ""), + grant_until=_str_or_none(raw.get("grant_until")), + generated_at=_str_or_none(raw.get("generated_at")), + ) + + @classmethod + def from_json(cls, payload: str | bytes) -> "DeviceConfig": + if isinstance(payload, (bytes, bytearray)): + payload = payload.decode("utf-8", "replace") + data = json.loads(payload) + if not isinstance(data, dict): + raise ValueError("config payload is not a JSON object") + return cls.from_dict(data) + + def to_json(self) -> str: + return json.dumps(self.to_dict(), sort_keys=True) + + def to_dict(self) -> dict[str, Any]: + return { + "v": self.v, + "device_id": self.device_id, + "name": self.name, + "platform": self.platform, + "locked": self.locked, + "until": self.until, + "allow": list(self.allow), + "block": list(self.block), + "reason": self.reason, + "grant_until": self.grant_until, + "generated_at": self.generated_at, + } + + # -- effective state -------------------------------------------------- + def until_at(self) -> datetime | None: + return parse_time(self.until) + + def grant_at(self) -> datetime | None: + return parse_time(self.grant_until) + + def is_blocking(self, now: datetime | None = None) -> bool: + """Whether the device must block right now. + + This mirrors the Control Service's *effective* state + (``internal/store/schedules.go`` ``EffectiveState``): the payload already + carries the resolved ``locked`` flag, so the client only has to apply + the clock. An expired ``until`` lifts the block locally — the same + instant the Control Service would clear it — a live ``grant_until`` + never blocks, and an indefinite lock (``until`` absent) never lifts. + """ + if not self.locked: + return False + moment = now or datetime.now(timezone.utc) + grant = self.grant_at() + if grant is not None and grant > moment: + return False + until = self.until_at() + if until is not None and moment >= until: + return False + return True + + def blocked_names(self) -> set[str]: + """The blocklist as normalised names, minus the allowlist (FR-4).""" + allowed = {bare(item) for item in self.allow} + return {bare(item) for item in self.block if bare(item)} - allowed + + def allowed_names(self) -> set[str]: + return {bare(item) for item in self.allow if bare(item)} + + def is_blocked_name(self, process_name: str) -> bool: + """Whether one process name is blocked, with the allowlist winning.""" + value = bare(process_name) + if not value: + return False + if value in self.allowed_names(): + return False + return value in self.blocked_names() + + @property + def reason_text(self) -> str: + """A short human reason for the overlay (FR-9).""" + return { + "schedule": "it is inside a scheduled screen-time window", + "manual": "a parent locked this computer", + "grant": "", + }.get(self.reason, self.reason or "screen-time rules are active") + + def generation_key(self) -> tuple[str, int]: + """A sortable key for "is this config newer?". + + The Control Service republishes a config whenever the state changes, so + ``generated_at`` is normally enough. The fallback keeps a payload with a + missing timestamp acceptable rather than dropping a real update. + """ + return (self.generated_at or "", self.device_id) + + +def _str_or_none(value: Any) -> str | None: + return None if value is None else str(value) + + +def _string_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, Iterable): + return [str(item) for item in value if str(item).strip()] + return [] + + +class ConfigCache: + """The last valid config on disk (FR-14 "keep last state"). + + A device that cannot reach the broker keeps enforcing what it already has. + The cache is written atomically, so a power cut cannot leave a half-written + file, and a cache that cannot be read is treated as empty rather than fatal. + """ + + def __init__(self, path: str | os.PathLike[str]): + self.path = Path(path) + self._current: DeviceConfig | None = None + + @property + def current(self) -> DeviceConfig | None: + """The in-memory config, loading from disk on first use.""" + if self._current is None: + self._current = self._read() + return self._current + + def load(self) -> DeviceConfig | None: + return self.current + + def save(self, config: DeviceConfig) -> None: + """Persist a config and make it current.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + temp = self.path.with_suffix(self.path.suffix + ".tmp") + with open(temp, "w", encoding="utf-8") as handle: + handle.write(config.to_json()) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp, self.path) + self._current = config + + def accept(self, payload: str | bytes) -> DeviceConfig | None: + """Validate and store a payload. + + Returns the stored config, or ``None`` when the payload is rejected (a + malformed message, a newer schema, or an older ``generated_at`` than the + cache). A rejection leaves the current config untouched. + """ + try: + candidate = DeviceConfig.from_json(payload) + except (ValueError, TypeError): + # json.JSONDecodeError is a ValueError. A broker bug or a hostile + # payload must never leave the device without rules (FR-14). + return None + existing = self.current + if existing is not None and candidate.generation_key() < existing.generation_key(): + return None + self.save(candidate) + return candidate + + # --------------------------------------------------------------------- + def _read(self) -> DeviceConfig | None: + try: + with open(self.path, "r", encoding="utf-8") as handle: + return DeviceConfig.from_json(handle.read()) + except FileNotFoundError: + return None + except (OSError, ValueError, json.JSONDecodeError): + return None diff --git a/windows/webrestrict/mqtt_link.py b/windows/webrestrict/mqtt_link.py new file mode 100644 index 0000000..f328fcc --- /dev/null +++ b/windows/webrestrict/mqtt_link.py @@ -0,0 +1,241 @@ +"""The MQTT link to the 8883 device plane (AGENTS.md §10 rule 6, FR-6). + +The client is one device account on the TLS listener. Its ACL lets it read only +``restrict//config`` and write only its own status and telemetry, so the +broker itself stops a child publishing a fake "unlocked" (TRANSPORT_DESIGN.md +"Security"). + +Presence is the broker's Last Will and Testament: the client publishes a +retained ``{"online": true}`` when it connects and registers the will +``{"online": false}`` on the same topic, so the Control Service learns the +device is gone the moment the connection drops without a clean goodbye (FR-6). + +A broker that is down is not fatal: the cached config stays in force (FR-14), +paho retries on a backoff, and the retained config is delivered again on +reconnect. +""" + +from __future__ import annotations + +import json +import logging +import ssl +from datetime import datetime, timezone +from typing import Callable, Mapping + +from .settings import Settings + +log = logging.getLogger("webrestrict.mqtt") + +QOS = 1 +KEEPALIVE_SECONDS = 30 + + +def iso_now(moment: datetime | None = None) -> str: + """RFC 3339 in UTC, as the Control Service writes timestamps.""" + current = moment or datetime.now(timezone.utc) + if current.tzinfo is None: + current = current.replace(tzinfo=timezone.utc) + return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def status_payload(locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> str: + """The status report the dashboard reads (FR-6, FR-9, FR-17).""" + body: dict[str, object] = {"online": True, "locked": locked, "app": app, "at": iso_now()} + if tamper: + body["tamper"] = {**tamper, "at": iso_now()} + return json.dumps(body, sort_keys=True) + + +def telemetry_payload(day: str, apps: Mapping[str, int]) -> str: + """A day's per-app totals. Not retained: it is a report, not a rule.""" + return json.dumps( + { + "day": day, + "apps": [ + {"name": name, "seconds": int(seconds)} + for name, seconds in sorted(apps.items()) + if name and int(seconds) > 0 + ], + "domains": [], + }, + sort_keys=True, + ) + + +class MqttLink: + """A reconnecting paho client wrapper with a small, testable surface.""" + + def __init__( + self, + settings: Settings, + on_config: Callable[[str], None], + on_connected: Callable[[], None] | None = None, + on_lost: Callable[[str], None] | None = None, + ): + self.settings = settings + self.on_config = on_config + self.on_connected = on_connected or (lambda: None) + self.on_lost = on_lost or (lambda reason: None) + self.connected = False + self.last_error = "" + self._client = None + self._stopped = False + self._unreachable_logged = False + + # -- lifecycle --------------------------------------------------------- + def start(self) -> None: + """Create the client and begin connecting in the background.""" + import paho.mqtt.client as mqtt # noqa: PLC0415 - keep the import local + + client = self._new_client(mqtt) + self._client = client + client.on_connect = self._handle_connect + client.on_connect_fail = self._handle_connect_fail + client.on_disconnect = self._handle_disconnect + client.on_message = self._handle_message + + client.username_pw_set(self.settings.mqtt_username, self.settings.mqtt_password or None) + if self.settings.ca_file: + client.tls_set( + ca_certs=self.settings.ca_file, + cert_reqs=ssl.CERT_REQUIRED, + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ) + else: # pragma: no cover - the lab always pins the CA + client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT) + + # Presence (FR-6): the broker publishes this the moment the link drops + # without a clean DISCONNECT. + client.will_set( + self.settings.status_topic, + json.dumps({"online": False}), + qos=QOS, + retain=True, + ) + client.reconnect_delay_set(min_delay=1, max_delay=30) + log.info( + "mqtt connecting to %s:%s as %s (topic %s)", + self.settings.broker_host, + self.settings.broker_port, + self.settings.mqtt_username, + self.settings.device_topic, + ) + client.connect_async( + self.settings.broker_host, + self.settings.broker_port, + keepalive=KEEPALIVE_SECONDS, + ) + client.loop_start() + + def stop(self) -> None: + """Disconnect cleanly, which suppresses the will.""" + self._stopped = True + client = self._client + if client is None: + return + try: + client.disconnect() + client.loop_stop() + except Exception as exc: # noqa: BLE001 - shutting down anyway + log.debug("mqtt stop: %s", exc) + + # -- publishing -------------------------------------------------------- + def publish_status(self, locked: bool, app: str = "", tamper: dict[str, str] | None = None) -> bool: + return self._publish(self.settings.status_topic, status_payload(locked, app, tamper), retain=True) + + def publish_telemetry(self, day: str, apps: Mapping[str, int]) -> bool: + if not apps: + return False + return self._publish(self.settings.telemetry_topic, telemetry_payload(day, apps), retain=False) + + def publish_offline(self) -> bool: + """Retained offline status, published on a clean shutdown (FR-6).""" + return self._publish(self.settings.status_topic, json.dumps({"online": False}), retain=True) + + def _publish(self, topic: str, payload: str, *, retain: bool) -> bool: + client = self._client + if client is None or not self.connected: + return False + try: + info = client.publish(topic, payload, qos=QOS, retain=retain) + if info.rc != 0: + log.warning("publish to %s rejected (rc=%s)", topic, info.rc) + return False + return True + except Exception as exc: # noqa: BLE001 - a lost link retries + log.warning("publish to %s failed: %s", topic, exc) + return False + + # -- paho plumbing ----------------------------------------------------- + def _new_client(self, mqtt): + """paho 2.x takes an explicit callback API version; 1.x does not.""" + try: + return mqtt.Client( + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=self.settings.effective_client_id, + clean_session=True, + protocol=mqtt.MQTTv311, + ) + except TypeError: # pragma: no cover - paho 1.x + return mqtt.Client( + client_id=self.settings.effective_client_id, + clean_session=True, + protocol=mqtt.MQTTv311, + ) + + def _handle_connect(self, client, _userdata, _flags, reason_code, _properties=None): + code = getattr(reason_code, "value", reason_code) + try: + code = int(code) + except (TypeError, ValueError): + code = 1 + if code != 0: + self.connected = False + self.last_error = f"connect refused: {reason_code}" + log.warning("mqtt connect refused: %s", reason_code) + return + self.connected = True + self.last_error = "" + self._unreachable_logged = False + log.info("mqtt connected, subscribing to %s", self.settings.device_topic) + client.subscribe(self.settings.device_topic, qos=QOS) + # on_connected publishes the retained online status with the real + # locked state, and flushes any telemetry held while offline. + self.on_connected() + + def _handle_connect_fail(self, *_args): + """The broker could not be reached at all (FR-14). + + Logged once per outage so the record shows the client is running on its + cached config rather than sitting idle, and not flooded by paho's retry. + """ + self.connected = False + self.last_error = "broker unreachable" + if not self._unreachable_logged: + self._unreachable_logged = True + log.warning( + "broker %s:%s is unreachable; enforcing the cached config only (FR-14)", + self.settings.broker_host, + self.settings.broker_port, + ) + self.on_lost("broker unreachable") + + def _handle_disconnect(self, _client, _userdata, _flags, reason_code, _properties=None): + self.connected = False + message = str(reason_code) + self.last_error = message + if self._stopped: + log.info("mqtt disconnected (client stopped): %s", message) + return + log.warning("mqtt connection lost: %s — keeping the last config (FR-14)", message) + self.on_lost(message) + + def _handle_message(self, _client, _userdata, message): + if message.topic != self.settings.device_topic: + return + payload = message.payload.decode("utf-8", "replace") + try: + self.on_config(payload) + except Exception as exc: # noqa: BLE001 - a bad config must not kill the link + log.warning("config rejected, keeping the last one: %s", exc) diff --git a/windows/webrestrict/overlay.py b/windows/webrestrict/overlay.py new file mode 100644 index 0000000..ef0584f --- /dev/null +++ b/windows/webrestrict/overlay.py @@ -0,0 +1,147 @@ +"""The child's warning window (AGENTS.md FR-9). + +A small always-on-top Tkinter window: what is blocked, why, and until when. It +is an *alert*, not a defence in depth — the child can close it, and on Windows +the enforcement is the process block itself (FR-3) plus the presence and +offline reports that reach Sam (FR-6, FR-13). + +Tkinter wants one thread that owns the interpreter, so the window runs on its +own daemon thread and the agent talks to it through a queue. If Tk is missing, +or there is no interactive desktop (a service session, a headless test), the +overlay is simply unavailable and blocking continues. +""" + +from __future__ import annotations + +import logging +import queue +import threading +from dataclasses import dataclass +from typing import Sequence + +log = logging.getLogger("webrestrict.overlay") + +TITLE = "Screen time" + + +@dataclass(frozen=True) +class Notice: + """What the window shows while a block is active.""" + + reason: str + until: str + names: Sequence[str] + + @property + def blocked_text(self) -> str: + if not self.names: + return "This computer is blocked while the window is active." + shown = ", ".join(self.names[:8]) + if len(self.names) > 8: + shown += f", and {len(self.names) - 8} more" + return f"Blocked now: {shown}" + + +class Overlay: + """A queue-fed Tkinter window with a tiny, testable interface.""" + + def __init__(self, enabled: bool = True): + self.enabled = enabled + self.available = False + self._queue: "queue.Queue[tuple[str, object]]" = queue.Queue() + self._thread: threading.Thread | None = None + self._shown: Notice | None = None + + def start(self) -> None: + if not self.enabled: + log.info("overlay disabled by configuration") + return + self._thread = threading.Thread(target=self._run, name="webrestrict-overlay", daemon=True) + self._thread.start() + + def update(self, notice: Notice | None) -> None: + """Show the notice, hide on ``None``. Repeat calls with the same content are dropped.""" + if notice == self._shown: + return + self._shown = notice + if notice is None: + self._queue.put(("hide", None)) + else: + self._queue.put(("show", notice)) + + def stop(self) -> None: + self._queue.put(("stop", None)) + + # --------------------------------------------------------------------- + def _run(self) -> None: # pragma: no cover - needs a desktop + try: + import tkinter as tk + except ImportError as exc: + log.warning("tkinter is unavailable, no warning window: %s", exc) + return + + try: + root = tk.Tk() + except Exception as exc: # noqa: BLE001 - no interactive desktop + log.warning("the warning window is unavailable: %s", exc) + return + + root.withdraw() + root.title(TITLE) + root.attributes("-topmost", True) + root.resizable(False, False) + + frame = tk.Frame(root, padx=18, pady=16) + frame.pack(fill="both", expand=True) + heading = tk.Label(frame, text="Screen time is over", font=("Segoe UI", 14, "bold"), anchor="w") + heading.pack(fill="x") + reason_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w") + reason_label.pack(fill="x", pady=(8, 0)) + until_label = tk.Label(frame, text="", anchor="w", font=("Segoe UI", 9)) + until_label.pack(fill="x", pady=(6, 0)) + blocked_label = tk.Label(frame, text="", wraplength=380, justify="left", anchor="w") + blocked_label.pack(fill="x", pady=(8, 0)) + tk.Button(frame, text="Close warning", command=root.withdraw).pack(anchor="e", pady=(12, 0)) + root.geometry("420x260") + + self.available = True + log.info("warning window is available") + + def drain() -> None: # pragma: no cover - needs a desktop + stop = False + while True: + try: + action, payload = self._queue.get_nowait() + except queue.Empty: + break + if action == "show" and isinstance(payload, Notice): + reason_label.config(text=f"The rules say: {payload.reason}.") + until_label.config(text=f"Until {payload.until}." if payload.until else "No end time is set.") + blocked_label.config(text=payload.blocked_text) + root.deiconify() + root.lift() + root.attributes("-topmost", True) + place_bottom_right(root) + elif action == "hide": + root.withdraw() + elif action == "stop": + stop = True + if stop: + root.destroy() + return + root.after(200, drain) + + root.after(200, drain) + try: + root.mainloop() + except Exception as exc: # noqa: BLE001 - nothing left to do + log.debug("warning window loop ended: %s", exc) + + +def place_bottom_right(root) -> None: # pragma: no cover - needs a desktop + """Park the window bottom-right, out of the way of what it is covering.""" + root.update_idletasks() + width = root.winfo_width() or 420 + height = root.winfo_height() or 260 + screen = root.winfo_screenwidth(), root.winfo_screenheight() + root.geometry(f"{width}x{height}+{max(screen[0] - width - 24, 0)}+{max(screen[1] - height - 72, 0)}") diff --git a/windows/webrestrict/process_watch.py b/windows/webrestrict/process_watch.py new file mode 100644 index 0000000..efb97eb --- /dev/null +++ b/windows/webrestrict/process_watch.py @@ -0,0 +1,263 @@ +"""The Windows side of enforcement: process list, window list, foregound app. + +Everything here touches the real machine, so it is kept apart from the pure +decision in :mod:`webrestrict.blocker`. ``psutil`` is the only third-party +dependency; the window queries go through ``ctypes`` and ``user32`` so the +client needs no ``pywin32`` install. + +Off Windows (unit tests, a lab box) every function degrades to an empty result +instead of raising, so the client stays importable and testable. +""" + +from __future__ import annotations + +import ctypes +import logging +import os +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable + +from .blocker import Victim, select_victims, self_protection +from .config import DeviceConfig, bare + +log = logging.getLogger("webrestrict.process") + +IS_WINDOWS = os.name == "nt" + +# How long to give a polite terminate() before the hard kill(), in seconds. +TERMINATE_GRACE_SECONDS = 3.0 + + +def _psutil(): + """Import psutil on demand, with a readable error when it is missing.""" + try: + import psutil # noqa: PLC0415 (deliberate: keeps the import cost local) + except ImportError as exc: # pragma: no cover - depends on the machine + raise RuntimeError( + "psutil is not installed; run: python -m pip install -r windows/requirements.txt" + ) from exc + return psutil + + +def snapshot_processes() -> list[tuple[int, str]]: + """Every running process as ``(pid, name)``. + + A process that exits while the list is built, or that denies access, is + skipped: it is not a reason to fail an enforcement tick. + """ + psutil = _psutil() + out: list[tuple[int, str]] = [] + for proc in psutil.process_iter(["pid", "name"]): + try: + info = proc.info + name = info.get("name") or "" + pid = int(info.get("pid") or 0) + except Exception: # noqa: BLE001 - a dead process is normal + continue + if pid and name: + out.append((pid, name)) + return out + + +def kill_process(pid: int, name: str) -> str: + """Terminate one process. Returns ``"terminated"`` or ``"killed"``. + + ``terminate()`` asks the app to close, which is exactly what a scheduled + window does to a game. If it is still alive after a short grace, ``kill()`` + ends it. Access denied is reported, not raised: the machine keeps running. + """ + psutil = _psutil() + try: + proc = psutil.Process(pid) + except Exception as exc: # noqa: BLE001 - the process is already gone + log.debug("process %s (%s) vanished: %s", pid, name, exc) + return "gone" + + try: + proc.terminate() + except Exception as exc: # noqa: BLE001 - e.g. AccessDenied for another user + log.warning("terminate %s (%s) failed: %s", pid, name, exc) + return "failed" + + try: + proc.wait(timeout=TERMINATE_GRACE_SECONDS) + return "terminated" + except Exception: # noqa: BLE001 - still running, escalate + pass + + try: + proc.kill() + proc.wait(timeout=TERMINATE_GRACE_SECONDS) + return "killed" + except Exception as exc: # noqa: BLE001 + log.warning("kill %s (%s) failed: %s", pid, name, exc) + return "failed" + + +# --- Windows window probes (ctypes; no pywin32) -------------------------- + +_WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) if IS_WINDOWS else None + + +def _user32(): + return ctypes.windll.user32 # type: ignore[attr-defined] + + +def window_pids() -> set[int]: + """Pids that own a visible, titled top-level window. + + That is the closest cheap proxy Windows offers for "the child can see this + app". A background app with a hidden window does not count. + """ + if not IS_WINDOWS: # pragma: no cover - platform dependent + return set() + + user32 = _user32() + pids: set[int] = set() + + def callback(hwnd, _lparam): # pragma: no cover - platform dependent + try: + if not user32.IsWindowVisible(hwnd): + return True + if user32.GetWindowTextLengthW(hwnd) == 0: + return True + pid = ctypes.c_ulong(0) + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value: + pids.add(int(pid.value)) + except Exception: # noqa: BLE001 - never let a callback break the enum + return True + return True + + try: + user32.EnumWindows(_WNDENUMPROC(callback), 0) + except Exception as exc: # noqa: BLE001 - pragma: no cover + log.debug("EnumWindows failed: %s", exc) + return pids + + +def foreground_pid() -> int | None: + """The pid of the window the child is using, or ``None`` off Windows.""" + if not IS_WINDOWS: # pragma: no cover - platform dependent + return None + try: + hwnd = _user32().GetForegroundWindow() + if not hwnd: + return None + pid = ctypes.c_ulong(0) + _user32().GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + return int(pid.value) or None + except Exception: # noqa: BLE001 - pragma: no cover + return None + + +def process_name(pid: int) -> str: + """The image name for one pid, or an empty string.""" + psutil = _psutil() + try: + return psutil.Process(pid).name() or "" + except Exception: # noqa: BLE001 - the process ended + return "" + + +def foreground_app() -> str: + """The foreground app's image name, for the status report (FR-9).""" + pid = foreground_pid() + return process_name(pid) if pid else "" + + +def visible_apps(exclude_pids: Iterable[int] | None = None) -> list[str]: + """Names of processes with a visible, titled window right now. + + The client's own process is excluded by default: its warning window has a + title and would otherwise be counted as the child's usage. + """ + pids = window_pids() + if not pids: + return [] + skip = {int(pid) for pid in (exclude_pids if exclude_pids is not None else (os.getpid(),))} + pids -= skip + return sorted({name for name in (process_name(pid) for pid in pids) if name}) + + +@dataclass +class EnforcementReport: + """What one tick did, for the log and the status report.""" + + blocking: bool + blocked_names: tuple[str, ...] + killed: tuple[Victim, ...] + failed: tuple[Victim, ...] + + @property + def killed_names(self) -> list[str]: + return sorted({victim.matched for victim in self.killed}) + + +class Enforcer: + """Terminates blocked processes while the device is effectively locked.""" + + def __init__(self, protected: Iterable[str], log_extra: logging.Logger | None = None): + self.log = log_extra or log + self.protected_names = {bare(name) for name in protected if name and name.strip()} + self.protected_pids: set[int] = set() + # The client's own process tree is never a victim, even if a blocklist + # names python.exe: killing itself would end enforcement silently. + parents = _parent_pids(os.getpid()) + pids, names = self_protection(os.getpid(), {os.path.basename(sys.executable), "python", "pythonw"}, parents) + self.protected_pids |= pids + self.protected_names |= {bare(name) for name in names} + + def enforce(self, config: DeviceConfig, now: datetime | None = None) -> EnforcementReport: + """One enforcement tick: find the victims, kill them, report the result.""" + blocking = config.is_blocking(now) + victims = select_victims( + snapshot_processes(), + config, + now=now, + protected_names=self.protected_names, + protected_pids=self.protected_pids, + ) + killed: list[Victim] = [] + failed: list[Victim] = [] + for victim in victims: + outcome = kill_process(victim.pid, victim.name) + if outcome in ("terminated", "killed"): + self.log.info( + "blocked %s (pid=%s, rule=%s): %s", victim.name, victim.pid, victim.matched, outcome + ) + killed.append(victim) + elif outcome == "failed": + failed.append(victim) + if killed: + self.log.info("enforcement killed %d process(es): %s", len(killed), ", ".join( + f"{v.name}({v.pid})" for v in killed + )) + return EnforcementReport( + blocking=blocking, + blocked_names=tuple(sorted(config.blocked_names())), + killed=tuple(killed), + failed=tuple(failed), + ) + + +def _parent_pids(pid: int) -> set[int]: + """The pid's ancestor chain, skipping failures.""" + psutil = _psutil() + out: set[int] = set() + try: + proc = psutil.Process(pid) + except Exception: # noqa: BLE001 - pragma: no cover + return out + for _ in range(10): + try: + parent = proc.parent() + except Exception: # noqa: BLE001 + break + if parent is None or parent.pid in out: + break + out.add(parent.pid) + proc = parent + return out diff --git a/windows/webrestrict/settings.py b/windows/webrestrict/settings.py new file mode 100644 index 0000000..5351a74 --- /dev/null +++ b/windows/webrestrict/settings.py @@ -0,0 +1,178 @@ +"""Client settings, read from one JSON file (AGENTS.md §11 Phase 4). + +Everything the client needs is local configuration: the broker address, the +device's own topic and MQTT account, and where to keep its state. Nothing here +is a second source of truth — the Control Service still owns the rules. + +The file is provisioned on the machine and is **never** committed. It holds the +device's MQTT password, which must stay off the repository (AGENTS.md §10 rule +8). The path comes from ``--config``, then ``WEBRESTRICT_CONFIG``, then +``%PROGRAMDATA%\\WebRestrict\\client.json``. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# Default location. ProgramData is machine-wide and readable by the New Comp +# account, which is what the logon Scheduled Task runs as. +DEFAULT_CONFIG_NAME = "client.json" +DEFAULT_STATE_DIR_NAME = "WebRestrict" + +# Never kill these, whatever a blocklist says. The client must not be able to +# take the machine down, and it must not kill itself (it runs as python.exe). +# The client's own interpreter and its parent chain are added at runtime. +DEFAULT_PROTECTED: tuple[str, ...] = ( + "system", + "system idle process", + "registry", + "smss", + "csrss", + "wininit", + "winlogon", + "services", + "lsass", + "svchost", + "dwm", + "explorer", + "taskhostw", + "sihost", + "ctfmon", + "conhost", + "fontdrvhost", + "audiodg", + "msmpeng", + "securityhealthservice", + "searchindexer", + "startmenuexperiencehost", + "shellexperiencehost", + "textinputhost", + "applicationframehost", + "python", + "pythonw", +) + + +def default_program_data() -> Path: + """The machine-wide directory for client configuration and state.""" + base = os.environ.get("PROGRAMDATA") or os.environ.get("ALLUSERSPROFILE") + if base: + return Path(base) / DEFAULT_STATE_DIR_NAME + # Not Windows (unit tests, a lab box): keep it under the user's home. + return Path.home() / ".webrestrict" + + +def default_config_path() -> Path: + """The config path: ``WEBRESTRICT_CONFIG`` wins, else ``%PROGRAMDATA%``.""" + override = os.environ.get("WEBRESTRICT_CONFIG") + if override: + return Path(override) + return default_program_data() / DEFAULT_CONFIG_NAME + + +@dataclass +class Settings: + """Everything the client needs to run, with safe lab defaults. + + A missing field falls back to the default, so a hand-written config file + only needs the values that differ. Unknown fields are ignored, so the file + may grow without a client update. + """ + + # --- the link --------------------------------------------------------- + broker_host: str = "192.168.20.13" + broker_port: int = 8883 + ca_file: str = "" # empty = the platform trust store + device_topic: str = "" + mqtt_username: str = "" + mqtt_password: str = "" + client_id: str = "" # empty = derive from the topic token + + # --- local state ------------------------------------------------------ + state_dir: str = "" + + # --- timings (seconds) ------------------------------------------------ + poll_seconds: float = 5.0 + telemetry_seconds: float = 60.0 + status_seconds: float = 60.0 + connect_timeout_seconds: float = 10.0 + + # --- behaviour -------------------------------------------------------- + overlay: bool = True + protected: list[str] = field(default_factory=lambda: list(DEFAULT_PROTECTED)) + + # --------------------------------------------------------------------- + @property + def state_path(self) -> Path: + """The directory holding the cached config, the usage roll-up and the log.""" + if self.state_dir: + return Path(self.state_dir) + if self.device_token: + return default_program_data() / self.device_token + return default_program_data() + + @property + def device_token(self) -> str: + """The middle topic level, which is also the broker account name.""" + base = self.device_topic.removesuffix("/config") + return base.rsplit("/", 1)[-1] if "/" in base else base + + @property + def status_topic(self) -> str: + return self.device_topic.removesuffix("/config") + "/status" + + @property + def telemetry_topic(self) -> str: + return self.device_topic.removesuffix("/config") + "/telemetry" + + @property + def effective_client_id(self) -> str: + return self.client_id or f"kid-{self.device_token or 'windows'}" + + def config_path(self) -> Path: + return self.state_path / "config-cache.json" + + def telemetry_path(self) -> Path: + return self.state_path / "telemetry.json" + + def log_path(self) -> Path: + return self.state_path / "client.log" + + def validate(self) -> None: + """Raise when the client cannot possibly work, instead of failing later.""" + if not self.device_topic: + raise ValueError("device_topic is not set (e.g. restrict/finn_s_computer_2/config)") + if not self.device_topic.endswith("/config"): + raise ValueError(f"device_topic must end with /config: {self.device_topic!r}") + if not self.broker_host: + raise ValueError("broker_host is not set") + if not 1 <= self.broker_port <= 65535: + raise ValueError(f"broker_port is out of range: {self.broker_port}") + if not self.mqtt_username: + raise ValueError("mqtt_username is not set (it is the topic token)") + + # --------------------------------------------------------------------- + @classmethod + def from_dict(cls, raw: dict[str, Any]) -> "Settings": + """Build settings from a parsed JSON object, ignoring unknown keys.""" + known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined] + data = {k: v for k, v in raw.items() if k in known} + settings = cls(**data) + settings.broker_port = int(settings.broker_port) + return settings + + @classmethod + def load(cls, path: str | os.PathLike[str] | None = None) -> "Settings": + """Read the config file and validate it.""" + target = Path(path) if path else default_config_path() + with open(target, "r", encoding="utf-8") as handle: + raw = json.load(handle) + if not isinstance(raw, dict): + raise ValueError(f"{target}: expected a JSON object") + settings = cls.from_dict(raw) + settings.validate() + return settings diff --git a/windows/webrestrict/telemetry.py b/windows/webrestrict/telemetry.py new file mode 100644 index 0000000..9c1e691 --- /dev/null +++ b/windows/webrestrict/telemetry.py @@ -0,0 +1,155 @@ +"""Daily usage roll-up and its local store (AGENTS.md FR-7). + +The client publishes ``{"day":...,"apps":[{"name":...,"seconds":N}]}`` where the +seconds are **totals for the day**, not deltas: the Control Service replaces the +row for (device, day, app) on every report (``internal/store/migrations/002``). + +Windows has no per-app foreground clock like Android's Usage Access, so the +client builds its own total: every sample, the elapsed seconds are added to each +app that currently owns a visible, titled top-level window. That total is kept +on disk so a restart never loses the day, and it is republished when the link +returns (FR-14). + +**Domains are not attributed.** Windows gives no cheap per-process DNS answer — +it needs a packet capture, an ETW consumer or the DNS Client event log, all of +which are either a second dependency or an administrator-only channel. The +client therefore publishes ``"domains": []`` and the gap is recorded in +``windows/README.md`` rather than filled with a guess. +""" + +from __future__ import annotations + +import json +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, Mapping + +log = logging.getLogger("webrestrict.telemetry") + +# Keep two weeks of daily totals on the device, matching the Android client. +MAX_DAYS = 14 + +# A sample longer than this is treated as this long, so a laptop that was asleep +# does not credit one app with hours of "use". +MAX_SAMPLE_SECONDS = 120.0 + + +def day_key(moment: datetime | None = None) -> str: + """The local ``YYYY-MM-DD`` the usage belongs to. + + An aware timestamp is converted to local time first, so a day boundary is the + child's midnight rather than UTC's. + """ + current = moment or datetime.now() + if current.tzinfo is not None: + current = current.astimezone() + return current.strftime("%Y-%m-%d") + + +def to_entries(apps: Mapping[str, int]) -> list[dict[str, object]]: + """The list form the telemetry topic accepts, sorted and positive-only.""" + return [ + {"name": name, "seconds": int(seconds)} + for name, seconds in sorted(apps.items()) + if name and int(seconds) > 0 + ] + + +def build_payload(day: str, apps: Mapping[str, int]) -> dict[str, object]: + """The Phase 3 telemetry payload (``internal/mqtt/payload.go``).""" + return {"day": day, "apps": to_entries(apps), "domains": []} + + +@dataclass +class TelemetryStore: + """day -> app -> seconds, kept on disk so a restart or outage loses nothing.""" + + path: Path + days: dict[str, dict[str, int]] = field(default_factory=dict) + loaded: bool = False + + def load(self) -> "TelemetryStore": + if self.loaded: + return self + self.loaded = True + try: + if not self.path.exists(): + return self + with open(self.path, "r", encoding="utf-8") as handle: + raw = json.load(handle) + days = raw.get("days") if isinstance(raw, dict) else None + if isinstance(days, dict): + self.days = { + str(day): {str(name): int(seconds) for name, seconds in row.items()} + for day, row in days.items() + if isinstance(row, dict) + } + except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc: + log.warning("telemetry store is unreadable, starting empty: %s", exc) + self.days = {} + return self + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + temp = self.path.with_suffix(self.path.suffix + ".tmp") + with open(temp, "w", encoding="utf-8") as handle: + json.dump({"days": self.days}, handle, sort_keys=True) + os.replace(temp, self.path) + + def day(self, day: str) -> dict[str, int]: + self.load() + return dict(self.days.get(day, {})) + + def add_seconds(self, day: str, apps: Mapping[str, int]) -> dict[str, int]: + """Add a sample to a day and return that day's new totals.""" + self.load() + row = self.days.setdefault(day, {}) + for name, seconds in apps.items(): + if not name or seconds <= 0: + continue + row[name] = row.get(name, 0) + int(seconds) + self.prune() + self.save() + return dict(row) + + def prune(self) -> None: + """Keep only the [MAX_DAYS] most recent days.""" + if len(self.days) <= MAX_DAYS: + return + for old in sorted(self.days)[:-MAX_DAYS]: + self.days.pop(old, None) + + +class UsageSampler: + """Turns "these apps had a window" ticks into per-app daily seconds.""" + + def __init__(self, store: TelemetryStore): + self.store = store + self.last_at: datetime | None = None + + def sample( + self, + apps: Iterable[str], + now: datetime | None = None, + day: str | None = None, + ) -> dict[str, int]: + """Credit the elapsed time to each app in the sample. + + Returns the day's totals. The first call only starts the clock: there is + no way to know what ran before the client started. + """ + moment = now or datetime.now(timezone.utc) + self.last_at = self.last_at or moment + elapsed = (moment - self.last_at).total_seconds() + self.last_at = moment + if elapsed <= 0: + return self.store.day(day or day_key(moment)) + elapsed = min(elapsed, MAX_SAMPLE_SECONDS) + + wanted = sorted({name for name in apps if name}) + if not wanted: + return self.store.day(day or day_key(moment)) + return self.store.add_seconds(day or day_key(moment), {name: int(elapsed) for name in wanted})